From 6f3845baa4b13586a15b2a2fad6e012a0ab080f8 Mon Sep 17 00:00:00 2001 From: KyuJoo Han Date: Fri, 31 Jul 2026 05:29:41 +0900 Subject: [PATCH 001/123] fix(checks): rank successful checks above skipped and neutral (#11337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(checks): rank successful checks above skipped and neutral Checks were ordered with `skipped` (4) and `neutral` (3) ahead of `success` (5), so a PR with a long tail of skipped jobs pushed every passing check below the fold — you scroll past a wall of "Skipped" to find out whether anything actually ran. Rank the no-signal conclusions last (`success` 3, `neutral` 4, `skipped` 5) and pull the order out of its three duplicated copies (checks-panel-content, PullRequestPage, GitHubItemDialog) into `src/shared/pr-check-severity-order.ts`. Unknown conclusions now sink to the bottom instead of silently ranking as `neutral`. * fix(checks): look up check ranks through a Map, not an object literal An object-literal rank table resolves `constructor`, `toString`, and `__proto__` off Object.prototype, so those keys returned a function instead of falling through to UNKNOWN_CHECK_RANK — the comparator then subtracted functions, went NaN, and left the list in arbitrary order. Conclusions come from provider payloads, so keep the lookup on a Map and cover prototype property names in the test. * test(checks): cover provider-neutral ordering states * fix(checks): preserve actionable provider states * fix(checks): preserve unresolved provider rollups * fix(checks): keep unknown GitLab rollups neutral * fix: preserve neutral review check summaries * fix: complete provider-neutral check ordering remediation * fix: use provider-neutral mobile review status input * fix: hydrate GitLab mobile review status * fix: type mobile GitLab review hydration --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/max-lines-baseline.txt | 1 - mobile/app/h/[hostId]/tasks.tsx | 185 +++++++++--------- mobile/src/session/github-pr-value-readers.ts | 15 +- mobile/src/tasks/github-check-summary.test.ts | 27 ++- mobile/src/tasks/github-check-summary.ts | 25 ++- mobile/src/tasks/gitlab-check-summary.ts | 37 ++++ .../tasks/mobile-hosted-check-status.test.ts | 71 +++++++ .../src/tasks/mobile-hosted-check-status.ts | 106 ++++++++++ src/main/github/client.ts | 14 +- src/main/github/mappers.ts | 42 +--- src/main/gitlab/mappers-workitem.test.ts | 12 ++ src/main/gitlab/mappers.test.ts | 16 +- src/main/gitlab/mappers.ts | 30 +-- .../src/components/GitHubItemDialog.tsx | 18 +- .../src/components/PullRequestPage.tsx | 18 +- src/renderer/src/components/TaskPage.tsx | 3 + .../components/github-pr-merge-state.test.ts | 75 ++++++- .../src/components/github-pr-merge-state.ts | 4 +- .../right-sidebar/checks-panel-content.tsx | 22 +-- .../task-page-cache-selectors.test.ts | 26 +++ .../components/task-page-cache-selectors.ts | 160 ++------------- .../task-page-linear-cache-selectors.ts | 92 +++++++++ .../task-page-pr-check-summary.test.ts | 33 +++- .../components/task-page-pr-check-summary.ts | 18 +- .../task-page-work-item-signatures.ts | 54 +++++ .../src/store/slices/github-checks.test.ts | 7 + .../src/store/slices/github-checks.ts | 34 +--- src/shared/gitlab-pipeline-checks.test.ts | 2 +- src/shared/gitlab-pipeline-checks.ts | 6 +- src/shared/gitlab-types.ts | 7 +- src/shared/hosted-review-github.ts | 28 +-- src/shared/hosted-review-gitlab.ts | 22 +-- src/shared/pr-check-severity-order.test.ts | 126 ++++++++++++ src/shared/pr-check-severity-order.ts | 36 ++++ src/shared/pr-check-status.test.ts | 36 ++++ src/shared/pr-check-status.ts | 74 +++++++ src/shared/types.ts | 7 +- 37 files changed, 1006 insertions(+), 483 deletions(-) create mode 100644 mobile/src/tasks/gitlab-check-summary.ts create mode 100644 mobile/src/tasks/mobile-hosted-check-status.test.ts create mode 100644 mobile/src/tasks/mobile-hosted-check-status.ts create mode 100644 src/renderer/src/components/task-page-linear-cache-selectors.ts create mode 100644 src/renderer/src/components/task-page-work-item-signatures.ts create mode 100644 src/shared/pr-check-severity-order.test.ts create mode 100644 src/shared/pr-check-severity-order.ts create mode 100644 src/shared/pr-check-status.test.ts create mode 100644 src/shared/pr-check-status.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index be7d1cd9080..73aca76ac6f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.tsx inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts inline src/renderer/src/components/tab-group/useTabDragSplit.ts inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts -inline src/renderer/src/components/task-page-cache-selectors.ts inline src/renderer/src/components/terminal-pane/TerminalPane.tsx inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index f3980bb31b8..c41b165cbf9 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -60,6 +60,13 @@ import { resolveMobileSyntaxLanguage } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' +import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary' +import { + getHostedMergeLabel, + getHostedReviewLabel, + getHostedReviewSignalTone, + getHostedChecksLabel +} from '../../../src/tasks/mobile-hosted-check-status' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { @@ -138,11 +145,13 @@ import { import type { BaseRefSearchResult, GitHubOwnerRepo, + ProviderCheckSummary, PersistedTrustedOrcaHooks, SparsePreset, TuiAgent } from '../../../../src/shared/types' import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import type { HostedReviewDecision } from '../../../../src/shared/hosted-review' import { githubProjectHost, githubProjectIdentityKey as githubProjectKey @@ -181,31 +190,20 @@ type GitHubWorkItem = { reviewDecision?: string | null reviewRequests?: GitHubAssignableUser[] latestReviews?: GitHubPRReviewSummary[] - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary mergeable?: GitHubPRMergeableState mergeStateStatus?: string | null } - type GitHubAssignableUser = { login: string name?: string | null avatarUrl?: string | null } - type GitHubPRReviewSummary = { login: string state?: string | null avatarUrl?: string | null } - -type GitHubPRCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' - total: number - passed: number - failed: number - pending: number -} - type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' type GitHubPRReviewerRow = { @@ -214,13 +212,11 @@ type GitHubPRReviewerRow = { avatarUrl?: string | null stateLabel: string } - type GitHubRepoSources = { issues: GitHubOwnerRepo | null prs: GitHubOwnerRepo | null upstreamCandidate: GitHubOwnerRepo | null } - type TaskRuntimeStatus = { capabilities?: string[] } @@ -229,7 +225,6 @@ type TasksSupportState = | { kind: 'unknown'; client: RpcClient | null } | { kind: 'supported'; client: RpcClient } | { kind: 'unsupported'; client: RpcClient } - type GitLabWorkItem = { id: string type: 'issue' | 'mr' @@ -244,6 +239,10 @@ type GitLabWorkItem = { baseRefName?: string isCrossRepository?: boolean projectRef?: { host: string; path: string } + checksSummary?: ProviderCheckSummary + mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' + reviewDecision?: HostedReviewDecision + reviewerCount?: number repoId: string repoName: string } @@ -1262,23 +1261,6 @@ function hostedBranchSummary(item: TaskItem): { head: string; base: string } | n return null } -function getGitHubChecksLabel(item: GitHubWorkItem): string { - const summary = item.checksSummary - if (!summary) { - return 'Checks' - } - if (summary.total === 0) { - return 'No checks' - } - if (summary.failed > 0) { - return `${summary.failed} failing` - } - if (summary.pending > 0) { - return `${summary.pending} pending` - } - return `${summary.passed}/${summary.total} passed` -} - function getGitHubMergeLabel(item: GitHubWorkItem): string { if (item.mergeable === undefined && item.mergeStateStatus === undefined) { return 'Merge' @@ -1382,46 +1364,6 @@ function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string { return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}` } -function getGitHubPRSignalTone( - item: GitHubWorkItem, - signal: 'review' | 'checks' | 'merge' -): 'neutral' | 'success' | 'warning' | 'danger' { - if (signal === 'review') { - if (item.reviewDecision === 'APPROVED') { - return 'success' - } - if (item.reviewDecision === 'CHANGES_REQUESTED') { - return 'danger' - } - if (item.reviewRequests && item.reviewRequests.length > 0) { - return 'warning' - } - return 'neutral' - } - if (signal === 'checks') { - if (item.checksSummary?.state === 'success') { - return 'success' - } - if (item.checksSummary?.state === 'failure') { - return 'danger' - } - if (item.checksSummary?.state === 'pending') { - return 'warning' - } - return 'neutral' - } - if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { - return 'danger' - } - if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { - return 'warning' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'success' - } - return 'neutral' -} - function mergeGitHubAssignableUsers( users: GitHubAssignableUser[], seeds: GitHubAssignableUser[] @@ -4345,7 +4287,7 @@ export default function MobileTasksScreen() { const details = response.result as { body?: string comments?: DetailComment[] - item?: { labels?: string[] } + item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' } assignees?: string[] pipelineJobs?: Array<{ id?: number @@ -4355,6 +4297,8 @@ export default function MobileTasksScreen() { webUrl?: string | null duration?: number | null }> + reviewers?: unknown[] + approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null } } | null if (!details) { throw new Error('Details not found') @@ -4368,6 +4312,44 @@ export default function MobileTasksScreen() { assignees: details.assignees ?? [], pipelineJobs: details.pipelineJobs ?? [] }) + const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? []) + const reviewDecision: Exclude | undefined = + details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0 + ? 'approved' + : details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0 + ? 'review_required' + : undefined + const hydratedStatus = { + ...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {}) + } + setActionItem((current) => + current?.provider === 'gitlab' && current.source.id === actionItem.source.id + ? { + ...current, + source: { + ...current.source, + checksSummary, + ...hydratedStatus + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id + ? { + ...candidate, + source: { + ...candidate.source, + checksSummary, + ...hydratedStatus + } + } + : candidate + ) + ) } return } @@ -9689,6 +9671,7 @@ export default function MobileTasksScreen() { const item = entry.item const repo = taskRepositoryMeta(item, reposById) const isGitHubPr = item.provider === 'github' && item.source.type === 'pr' + const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr' const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null const branchSummary = hostedBranchSummary(item) return ( @@ -9734,45 +9717,53 @@ export default function MobileTasksScreen() { ) : null} - {isGitHubPr ? ( + {isGitHubPr || isGitLabMr ? ( - {githubPrDelta ? ( + {isGitHubPr && githubPrDelta ? ( {githubPrDelta} ) : null} + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubReviewSummary(item.source) + : getHostedReviewLabel(item.source)} + + + ) : null} - {getGitHubReviewSummary(item.source)} - - - - - {getGitHubChecksLabel(item.source)} - - - - - {getGitHubMergeLabel(item.source)} + {getHostedChecksLabel(item.source)} + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubMergeLabel(item.source) + : getHostedMergeLabel(item.source)} + + + ) : null} ) : null} diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index 1756d51c59a..e1884829272 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -1,7 +1,7 @@ import type { CheckStatus, GitHubAssignableUser, - GitHubPRCheckSummary, + ProviderCheckSummary, GitHubPRMergeMethod, GitHubPRMergeMethodSettings, GitHubPRReviewSummary, @@ -182,12 +182,18 @@ export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSett } } -export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefined { +export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined { if (!isRecord(value)) { return undefined } const state = value.state - if (state !== 'success' && state !== 'failure' && state !== 'pending' && state !== 'none') { + if ( + state !== 'success' && + state !== 'failure' && + state !== 'pending' && + state !== 'neutral' && + state !== 'none' + ) { return undefined } return { @@ -195,6 +201,7 @@ export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefin total: readNumber(value.total) ?? 0, passed: readNumber(value.passed) ?? 0, failed: readNumber(value.failed) ?? 0, - pending: readNumber(value.pending) ?? 0 + pending: readNumber(value.pending) ?? 0, + neutral: readNumber(value.neutral) ?? 0 } } diff --git a/mobile/src/tasks/github-check-summary.test.ts b/mobile/src/tasks/github-check-summary.test.ts index 955059bbbe4..a173663d9a5 100644 --- a/mobile/src/tasks/github-check-summary.test.ts +++ b/mobile/src/tasks/github-check-summary.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildGitHubCheckSummary } from './github-check-summary' +import { buildGitLabCheckSummary } from './gitlab-check-summary' describe('buildGitHubCheckSummary', () => { it('returns none for empty check lists', () => { @@ -8,7 +9,8 @@ describe('buildGitHubCheckSummary', () => { total: 0, passed: 0, failed: 0, - pending: 0 + pending: 0, + neutral: 0 }) }) @@ -24,22 +26,35 @@ describe('buildGitHubCheckSummary', () => { total: 3, passed: 1, failed: 1, - pending: 1 + pending: 1, + neutral: 0 }) }) - it('marks all completed non-failing checks as successful', () => { + it('keeps neutral and unknown terminal conclusions out of passed', () => { expect( buildGitHubCheckSummary([ { status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'neutral' } ]) ).toEqual({ - state: 'success', + state: 'neutral', total: 2, - passed: 2, + passed: 1, failed: 0, - pending: 0 + pending: 0, + neutral: 1 + }) + }) + + it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => { + expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({ + state: 'neutral', + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 }) }) }) diff --git a/mobile/src/tasks/github-check-summary.ts b/mobile/src/tasks/github-check-summary.ts index c2064f302cd..a0006efb95d 100644 --- a/mobile/src/tasks/github-check-summary.ts +++ b/mobile/src/tasks/github-check-summary.ts @@ -4,11 +4,12 @@ export type GitHubCheckLike = { } export type GitHubCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' total: number passed: number failed: number pending: number + neutral: number } function isFailedCheck(check: GitHubCheckLike): boolean { @@ -28,18 +29,32 @@ function isPendingCheck(check: GitHubCheckLike): boolean { export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary { let failed = 0 let pending = 0 + let neutral = 0 for (const check of checks) { - if (isFailedCheck(check)) { + if (isFailedCheck(check) || check.conclusion === 'action_required') { failed += 1 } else if (isPendingCheck(check)) { pending += 1 + } else if (check.conclusion === 'success') { + continue + } else { + neutral += 1 } } const total = checks.length - const passed = Math.max(0, total - failed - pending) - const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success' + const passed = total - failed - pending - neutral + const state = + total === 0 + ? 'none' + : failed > 0 + ? 'failure' + : pending > 0 + ? 'pending' + : neutral > 0 + ? 'neutral' + : 'success' - return { state, total, passed, failed, pending } + return { state, total, passed, failed, pending, neutral } } diff --git a/mobile/src/tasks/gitlab-check-summary.ts b/mobile/src/tasks/gitlab-check-summary.ts new file mode 100644 index 00000000000..d984b6e4283 --- /dev/null +++ b/mobile/src/tasks/gitlab-check-summary.ts @@ -0,0 +1,37 @@ +import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary' +import type { ProviderCheckSummary } from '../../../src/shared/types' + +type GitLabPipelineJobLike = { status: string } + +export function buildGitLabCheckSummary(jobs: GitLabPipelineJobLike[]): ProviderCheckSummary { + return buildGitHubCheckSummary( + jobs.map((job): GitHubCheckLike => { + const status = job.status.toLowerCase() + const nonterminal = [ + 'created', + 'pending', + 'scheduled', + 'waiting_for_callback', + 'waiting_for_resource', + 'preparing' + ].includes(status) + return { + status: status === 'running' ? 'in_progress' : nonterminal ? 'queued' : 'completed', + conclusion: + status === 'success' + ? 'success' + : status === 'failed' + ? 'failure' + : ['canceled', 'canceling'].includes(status) + ? 'cancelled' + : status === 'skipped' + ? 'skipped' + : ['manual', 'action_required'].includes(status) + ? 'action_required' + : status === 'running' || nonterminal + ? 'pending' + : null + } + }) + ) +} diff --git a/mobile/src/tasks/mobile-hosted-check-status.test.ts b/mobile/src/tasks/mobile-hosted-check-status.test.ts new file mode 100644 index 00000000000..f98b71dc575 --- /dev/null +++ b/mobile/src/tasks/mobile-hosted-check-status.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { HostedReviewDecision } from '../../../src/shared/hosted-review' +import { + getHostedChecksLabel, + getHostedReviewLabel, + getHostedReviewSignalTone +} from './mobile-hosted-check-status' + +describe('mobile hosted check status', () => { + it('renders a hydrated neutral GitLab summary as unresolved checks', () => { + const summary = { + state: 'neutral' as const, + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 + } + expect(getHostedChecksLabel({ checksSummary: summary })).toBe('Unresolved checks') + expect(getHostedReviewSignalTone({ checksSummary: summary }, 'checks')).toBe('neutral') + }) + + it('accepts provider-neutral GitHub status fields without a shared work-item type', () => { + expect( + getHostedReviewSignalTone( + { reviewDecision: 'approved', reviewRequests: [], mergeable: 'UNKNOWN' }, + 'review' + ) + ).toBe('success') + }) + + it('renders hydrated GitLab approval, merge, and check states', () => { + expect(getHostedReviewLabel({ reviewDecision: 'review_required', reviewerCount: 2 })).toBe( + 'Review required' + ) + expect( + getHostedReviewSignalTone( + { + reviewDecision: 'review_required', + reviewerCount: 2, + mergeable: 'MERGEABLE', + checksSummary: { + state: 'success', + total: 1, + passed: 1, + failed: 0, + pending: 0, + neutral: 0 + } + }, + 'review' + ) + ).toBe('warning') + expect(getHostedReviewSignalTone({ mergeable: 'MERGEABLE' }, 'merge')).toBe('success') + expect(getHostedReviewSignalTone({ reviewDecision: 'approved' }, 'review')).toBe('success') + expect(getHostedReviewSignalTone({ mergeable: 'CONFLICTING' }, 'merge')).toBe('danger') + }) + + it('renders a shared typed GitLab review decision', () => { + const reviewDecision: HostedReviewDecision = 'changes_requested' + + expect(getHostedReviewLabel({ reviewDecision })).toBe('Changes requested') + expect(getHostedReviewSignalTone({ reviewDecision }, 'review')).toBe('danger') + }) + + it('keeps missing GitLab enrichment neutral', () => { + expect(getHostedReviewLabel({})).toBe('No reviewers') + expect(getHostedReviewSignalTone({}, 'review')).toBe('neutral') + expect(getHostedReviewSignalTone({}, 'merge')).toBe('neutral') + }) +}) diff --git a/mobile/src/tasks/mobile-hosted-check-status.ts b/mobile/src/tasks/mobile-hosted-check-status.ts new file mode 100644 index 00000000000..341d71bc814 --- /dev/null +++ b/mobile/src/tasks/mobile-hosted-check-status.ts @@ -0,0 +1,106 @@ +import type { ProviderCheckSummary, PRMergeableState } from '../../../src/shared/types' + +export type MobileHostedReviewStatus = { + checksSummary?: ProviderCheckSummary + reviewDecision?: string | null + reviewRequests?: readonly unknown[] + reviewerCount?: number + mergeable?: PRMergeableState + mergeStateStatus?: string | null +} + +export function getHostedReviewLabel(item: MobileHostedReviewStatus): string { + if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') { + return 'Approved' + } + if (item.reviewDecision === 'changes_requested' || item.reviewDecision === 'CHANGES_REQUESTED') { + return 'Changes requested' + } + if (item.reviewDecision === 'review_required' || item.reviewDecision === 'REVIEW_REQUIRED') { + return 'Review required' + } + const reviewerCount = item.reviewerCount ?? item.reviewRequests?.length + return reviewerCount + ? `${reviewerCount} reviewer${reviewerCount === 1 ? '' : 's'}` + : 'No reviewers' +} + +export function getHostedMergeLabel(item: MobileHostedReviewStatus): string { + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { + return 'Conflicts' + } + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'Behind' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'Able to merge' + } + return 'Unknown' +} + +export function getHostedChecksLabel(item: { checksSummary?: ProviderCheckSummary }): string { + const summary = item.checksSummary + if (!summary) { + return 'Checks' + } + if (summary.total === 0) { + return 'No checks' + } + if (summary.failed > 0) { + return `${summary.failed} failing` + } + if (summary.pending > 0) { + return `${summary.pending} pending` + } + return summary.state === 'neutral' + ? 'Unresolved checks' + : `${summary.passed}/${summary.total} passed` +} + +export function getHostedReviewSignalTone( + item: MobileHostedReviewStatus, + signal: 'review' | 'checks' | 'merge' +): 'neutral' | 'success' | 'warning' | 'danger' { + if (signal === 'review') { + if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') { + return 'success' + } + if ( + item.reviewDecision === 'changes_requested' || + item.reviewDecision === 'CHANGES_REQUESTED' + ) { + return 'danger' + } + if ( + item.reviewDecision === 'review_required' || + item.reviewDecision === 'REVIEW_REQUIRED' || + item.reviewerCount !== undefined || + item.reviewRequests?.length + ) { + return 'warning' + } + return 'neutral' + } + if (signal === 'checks') { + if (item.checksSummary?.state === 'success') { + return 'success' + } + if (item.checksSummary?.state === 'failure') { + return 'danger' + } + if (item.checksSummary?.state === 'pending') { + return 'warning' + } + return 'neutral' + } + if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') { + return 'danger' + } + if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') { + return 'warning' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'success' + } + return 'neutral' +} diff --git a/src/main/github/client.ts b/src/main/github/client.ts index f1df222b93d..d68c77ad263 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -658,11 +658,12 @@ function checkRollupEntries(value: unknown): unknown[] { function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] { const entries = checkRollupEntries(value) if (entries.length === 0) { - return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 } + return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 } } let passed = 0 let failed = 0 let pending = 0 + let neutral = 0 for (const entry of entries) { if (typeof entry !== 'object' || entry === null) { pending += 1 @@ -671,7 +672,7 @@ function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSumma const raw = entry as Record const conclusion = String(raw.conclusion ?? raw.state ?? '').toUpperCase() const status = String(raw.status ?? '').toUpperCase() - if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(conclusion)) { + if (['SUCCESS', 'SKIPPED'].includes(conclusion)) { passed += 1 } else if ( ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes( @@ -679,18 +680,19 @@ function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSumma ) ) { failed += 1 - } else if (status === 'COMPLETED' && conclusion) { - failed += 1 + } else if (status === 'COMPLETED') { + neutral += 1 } else { pending += 1 } } return { - state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success', + state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : neutral > 0 ? 'neutral' : 'success', total: entries.length, passed, failed, - pending + pending, + neutral } } diff --git a/src/main/github/mappers.ts b/src/main/github/mappers.ts index c61c8c9f55c..888b02ea527 100644 --- a/src/main/github/mappers.ts +++ b/src/main/github/mappers.ts @@ -1,4 +1,5 @@ import type { PRInfo, IssueInfo, CheckStatus, PRCheckDetail } from '../../shared/types' +import { derivePRCheckStatusFromRollup } from '../../shared/pr-check-status' // ── REST API check-runs mapping ─────────────────────────────────────── // The REST check-runs endpoint returns separate status + conclusion fields @@ -143,44 +144,5 @@ export function mapIssueInfo(data: { } export function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckStatus { - if (!rollup || !Array.isArray(rollup) || rollup.length === 0) { - return 'neutral' - } - - let hasFailure = false - let hasPending = false - - for (const check of rollup as { status?: string; conclusion?: string; state?: string }[]) { - const conclusion = check.conclusion?.toUpperCase() - const status = check.status?.toUpperCase() - const state = check.state?.toUpperCase() - - if ( - conclusion === 'FAILURE' || - conclusion === 'TIMED_OUT' || - conclusion === 'CANCELLED' || - // Why: action_required (e.g. an unapproved workflow run) blocks merge until - // someone acts; treat it as needs-attention rather than a silent pass. - conclusion === 'ACTION_REQUIRED' || - state === 'FAILURE' || - state === 'ERROR' - ) { - hasFailure = true - } else if ( - status === 'IN_PROGRESS' || - status === 'QUEUED' || - status === 'PENDING' || - state === 'PENDING' - ) { - hasPending = true - } - } - - if (hasFailure) { - return 'failure' - } - if (hasPending) { - return 'pending' - } - return 'success' + return derivePRCheckStatusFromRollup(rollup) } diff --git a/src/main/gitlab/mappers-workitem.test.ts b/src/main/gitlab/mappers-workitem.test.ts index 1c5fe731373..0bbcfec3bca 100644 --- a/src/main/gitlab/mappers-workitem.test.ts +++ b/src/main/gitlab/mappers-workitem.test.ts @@ -52,6 +52,18 @@ describe('mapMRToWorkItem', () => { expect(item.isCrossRepository).toBe(true) }) + it('maps GitLab mergeability when the MR payload includes merge status', () => { + expect( + mapMRToWorkItem( + { iid: 1, title: 't', state: 'opened', detailed_merge_status: 'mergeable' }, + 'g/p' + ).mergeable + ).toBe('MERGEABLE') + expect( + mapMRToWorkItem({ iid: 2, title: 't', state: 'opened', has_conflicts: true }, 'g/p').mergeable + ).toBe('CONFLICTING') + }) + it('does not flag cross-repository when project ids are absent', () => { const item = mapMRToWorkItem({ iid: 1, title: 't', state: 'opened' }, 'g/p') expect(item.isCrossRepository).toBe(false) diff --git a/src/main/gitlab/mappers.test.ts b/src/main/gitlab/mappers.test.ts index 91fd9b62b09..d96da3eb9be 100644 --- a/src/main/gitlab/mappers.test.ts +++ b/src/main/gitlab/mappers.test.ts @@ -38,8 +38,9 @@ describe('mapPipelineJobStatusToConclusion', () => { expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped') }) - it("maps 'manual' to neutral so it doesn't stall pending forever", () => { - expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral') + it('maps manual and action-required jobs to an actionable conclusion', () => { + expect(mapPipelineJobStatusToConclusion('manual')).toBe('action_required') + expect(mapPipelineJobStatusToConclusion('action_required')).toBe('action_required') }) it('maps active lifecycle states to pending', () => { @@ -227,7 +228,7 @@ describe('derivePipelineStatus', () => { expect(derivePipelineStatus('success')).toBe('success') expect(derivePipelineStatus('failed')).toBe('failure') expect(derivePipelineStatus('running')).toBe('pending') - expect(derivePipelineStatus('manual')).toBe('neutral') + expect(derivePipelineStatus('manual')).toBe('failure') }) it('rolls up an array of jobs', () => { @@ -238,9 +239,18 @@ describe('derivePipelineStatus', () => { it('failure beats pending in the rollup', () => { expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure') + expect(derivePipelineStatus([{ status: 'manual' }, { status: 'success' }])).toBe('failure') }) it('handles a single object with status', () => { expect(derivePipelineStatus({ status: 'success' })).toBe('success') }) + + it('keeps malformed and unknown array jobs neutral', () => { + expect(derivePipelineStatus([{ status: 'future_status' }])).toBe('neutral') + expect(derivePipelineStatus([{}])).toBe('neutral') + expect(derivePipelineStatus([{ status: 'success' }, { status: 'future_status' }])).toBe( + 'neutral' + ) + }) }) diff --git a/src/main/gitlab/mappers.ts b/src/main/gitlab/mappers.ts index 55dffb89c64..7b45c9b64f0 100644 --- a/src/main/gitlab/mappers.ts +++ b/src/main/gitlab/mappers.ts @@ -161,19 +161,20 @@ export function derivePipelineStatus( } let hasFailure = false let hasPending = false + let hasUnknown = false for (const job of rollup) { - const s = job.status?.toLowerCase() - if (s === 'failed') { - hasFailure = true - } else if ( - s === 'created' || - s === 'pending' || - s === 'running' || - s === 'waiting_for_resource' || - s === 'preparing' || - s === 'scheduled' + const s = job.status?.toLowerCase() ?? '' + const conclusion = mapPipelineJobStatusToConclusion(s) + if ( + conclusion === 'failure' || + conclusion === 'cancelled' || + conclusion === 'action_required' ) { + hasFailure = true + } else if (conclusion === 'pending') { hasPending = true + } else if (conclusion !== 'success' && conclusion !== 'skipped' && conclusion !== 'neutral') { + hasUnknown = true } } if (hasFailure) { @@ -182,7 +183,7 @@ export function derivePipelineStatus( if (hasPending) { return 'pending' } - return 'success' + return hasUnknown ? 'neutral' : 'success' } // ── Raw → GitLabWorkItem mapping ──────────────────────────────────── @@ -209,6 +210,8 @@ type GitLabMRRawForWorkItem = { * when the workspace flow can't safely resolve the head. */ source_project_id?: number target_project_id?: number + has_conflicts?: boolean + detailed_merge_status?: string } export function mapMRToWorkItem( @@ -237,6 +240,9 @@ export function mapMRToWorkItem( data.target_project_id !== undefined && data.source_project_id !== data.target_project_id, repoId, + ...(data.has_conflicts !== undefined || data.detailed_merge_status !== undefined + ? { mergeable: deriveMergeable(data) } + : {}), ...(projectRef ? { projectRef } : {}) } } @@ -283,7 +289,7 @@ function classifyPipelineString(status: string): CheckStatus { if (s === 'success') { return 'success' } - if (s === 'failed') { + if (s === 'failed' || s === 'manual' || s === 'action_required') { return 'failure' } if ( diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 920a45bfade..bdb96e71941 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -231,6 +231,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' // Why: the dialog lacks repository context, so recover its host-aware identity from the canonical item URL. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { @@ -4320,17 +4321,6 @@ function CommentReplyForm({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' } @@ -4474,11 +4464,7 @@ function ChecksTab({ const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = [...list].sort( - (a, b) => - (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - - (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) - ) + const sorted = sortChecksBySeverity(list) const failedChecks = getBrokenChecks(list) const counts = getCheckCounts(list) const summaryLabel = getChecksSummaryLabel(list) diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 029476d4e0d..a835ac96dd3 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -227,6 +227,7 @@ import { } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' // Why: the item URL is the only host-aware repository identity present on every work item across IPC. function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { @@ -4329,17 +4330,6 @@ function CommentReplyForm({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' } @@ -4578,11 +4568,7 @@ function ChecksTab({ const prRepo = useMemo(() => resolvePullRequestRepo(item), [item]) const runtimeHost = getGitHubSourceRuntimeHost(sourceContext) const canUseChecksRepoContext = canUseGitHubRepoContext(repoPath, sourceContext) - const sorted = [...list].sort( - (a, b) => - (CHECK_SORT_ORDER[getCheckConclusion(a)] ?? 3) - - (CHECK_SORT_ORDER[getCheckConclusion(b)] ?? 3) - ) + const sorted = sortChecksBySeverity(list) const failedChecks = getBrokenChecks(list) const counts = getCheckCounts(list) const summaryLabel = getChecksSummaryLabel(list) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 94eb63b148f..f9dac6c9fa3 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -2026,6 +2026,9 @@ function getChecksLabel(item: GitHubWorkItem): string { if (summary.pending > 0) { return `${summary.pending} pending` } + if (summary.neutral > 0) { + return `${summary.neutral} unresolved` + } return `${summary.passed}/${summary.total} passed` } diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index a2f52f8fd31..036d34c4c0d 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -6,7 +6,7 @@ function pr(overrides: Partial = {}): GitHubPRMergeStat state: 'open', mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN', - checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0 }, + checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 }, autoMergeAllowed: true, ...overrides } @@ -44,7 +44,14 @@ describe('presentGitHubPRMergeState', () => { presentGitHubPRMergeState( pr({ mergeStateStatus: 'BLOCKED', - checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } }) ).autoMergeAction ).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' }) @@ -53,7 +60,16 @@ describe('presentGitHubPRMergeState', () => { it('does not offer enable auto-merge when only optional checks are pending', () => { expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } }) + pr({ + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks pending', @@ -68,7 +84,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE', - checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } + checksSummary: { + state: 'failure', + total: 1, + passed: 0, + failed: 1, + pending: 0, + neutral: 0 + } }) ).autoMergeAction ).toBeNull() @@ -110,7 +133,7 @@ describe('presentGitHubPRMergeState', () => { expect( presentGitHubPRMergeState({ state: 'open', - checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0 } + checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0, neutral: 0 } }) ).toMatchObject({ label: 'Checks passed', directMergeAvailable: true, autoMergeAction: null }) expect(presentGitHubPRMergeState(pr())).toMatchObject({ @@ -135,12 +158,30 @@ describe('presentGitHubPRMergeState', () => { }) expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } }) + pr({ + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks pending', directMergeAvailable: true }) expect( presentGitHubPRMergeState( - pr({ checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } }) + pr({ + checksSummary: { + state: 'failure', + total: 1, + passed: 0, + failed: 1, + pending: 0, + neutral: 0 + } + }) ) ).toMatchObject({ label: 'Checks failed', directMergeAvailable: true }) expect(presentGitHubPRMergeState(pr())).toMatchObject({ @@ -155,7 +196,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: null, - checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } + checksSummary: { + state: 'pending', + total: 1, + passed: 0, + failed: 0, + pending: 1, + neutral: 0 + } }) ) ).toMatchObject({ @@ -168,7 +216,7 @@ describe('presentGitHubPRMergeState', () => { expect( presentGitHubPRMergeState({ state: 'open', - checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 } + checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0, neutral: 0 } }) ).toMatchObject({ label: 'Checks passed', @@ -179,7 +227,14 @@ describe('presentGitHubPRMergeState', () => { pr({ mergeable: 'UNKNOWN', mergeStateStatus: null, - checksSummary: { state: 'success', total: 3, passed: 3, failed: 0, pending: 0 } + checksSummary: { + state: 'success', + total: 3, + passed: 3, + failed: 0, + pending: 0, + neutral: 0 + } }) ) ).toMatchObject({ diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index a5b1663c234..ca6c47e71b4 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -1,6 +1,6 @@ import type { CheckStatus, - GitHubPRCheckSummary, + ProviderCheckSummary, PRMergeableState, PRReviewDecision, PRState @@ -14,7 +14,7 @@ export type GitHubPRMergeStateInput = { mergeStateStatus?: string | null reviewDecision?: PRReviewDecision | null checksStatus?: CheckStatus - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null mergeQueueRequired?: boolean | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index ab2716d1558..1d83301ae8e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -99,6 +99,7 @@ import { import { translate } from '@/i18n/i18n' import { useActiveWorktree } from '@/store/selectors' import { useAppStore } from '@/store' +import { sortChecksBySeverity } from '../../../../shared/pr-check-severity-order' export const PullRequestIcon = GitPullRequest @@ -504,17 +505,6 @@ export function ConflictTriageStrip({ ) } -const CHECK_SORT_ORDER: Record = { - failure: 0, - timed_out: 0, - action_required: 0, - cancelled: 1, - pending: 2, - neutral: 3, - skipped: 4, - success: 5 -} - type CheckDetailsLoadState = { loading: boolean details: PRCheckRunDetails | null @@ -970,15 +960,7 @@ export function ChecksList({ shouldConstrainCheckList && checks.length > 0 ) detailsContextRef.current = checkDetailsContextKey - const sorted = React.useMemo( - () => - [...checks].sort( - (a, b) => - (CHECK_SORT_ORDER[a.conclusion ?? 'pending'] ?? 3) - - (CHECK_SORT_ORDER[b.conclusion ?? 'pending'] ?? 3) - ), - [checks] - ) + const sorted = React.useMemo(() => sortChecksBySeverity(checks), [checks]) const rows = React.useMemo( () => sorted.map((check, index) => ({ diff --git a/src/renderer/src/components/task-page-cache-selectors.test.ts b/src/renderer/src/components/task-page-cache-selectors.test.ts index b1c9251251c..d06bf9ee623 100644 --- a/src/renderer/src/components/task-page-cache-selectors.test.ts +++ b/src/renderer/src/components/task-page-cache-selectors.test.ts @@ -46,6 +46,32 @@ describe('task page cache selectors', () => { }) }) + it('reconciles a changed neutral check count', () => { + const current = { + ...workItem('pr-1', 'repo-1'), + checksSummary: { + state: 'neutral' as const, + total: 1, + passed: 1, + failed: 0, + pending: 0, + neutral: 0 + } + } + const refreshed = { + ...current, + checksSummary: { + state: 'neutral' as const, + total: 2, + passed: 1, + failed: 0, + pending: 0, + neutral: 1 + } + } + expect(reconcileTaskPageItemsAfterLandingRefresh([current], [refreshed])).toEqual([refreshed]) + }) + it('keeps the selected work-item cache slice shallow-equal across unrelated cache writes', () => { const repo = { id: 'repo-1', path: '/repo/one' } const selectedEntry = entry([workItem('issue-1', 'repo-1')]) diff --git a/src/renderer/src/components/task-page-cache-selectors.ts b/src/renderer/src/components/task-page-cache-selectors.ts index 37644ce76db..5256701003f 100644 --- a/src/renderer/src/components/task-page-cache-selectors.ts +++ b/src/renderer/src/components/task-page-cache-selectors.ts @@ -1,12 +1,22 @@ -/* eslint-disable max-lines -- Why: TaskPage cache reconciliation helpers are - kept together so list refresh and drawer lookup behavior stay consistent. */ import { workItemsCacheKey, type CacheEntry, type WorkItemsCacheError, type WorkItemsCacheSources } from '@/store/slices/github' -import type { GitHubWorkItem, LinearCollectionResult, LinearIssue } from '../../../shared/types' +import type { GitHubWorkItem } from '../../../shared/types' +import { + taskPageWorkItemKey, + taskPageWorkItemStatusSignature, + taskPageWorkItemKeyOrderSignature, + taskPageWorkItemPaginationBoundary +} from './task-page-work-item-signatures' +export { + findTaskPageLinearDrawerIssue, + findTaskPageLinearIssue, + reconcileTaskPageLinearIssuesAfterLandingRefresh, + shouldReplaceTaskPageLinearIssuesAfterRefresh +} from './task-page-linear-cache-selectors' export type TaskPageRepoCacheInput = { id: string @@ -34,9 +44,6 @@ export type TaskPageWorkItemsFetchOptions = { } type WorkItemsCache = Record> -type LinearIssueCache = Record> -type LinearSearchCache = Record> -type LinearListCache = Record>> export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[] export function deriveTaskPageGitHubWorkItemsFetchOptions( @@ -149,56 +156,6 @@ export function reconcileTaskPagePagesWithWorkItemsCache( return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[]) } -function taskPageWorkItemKey(item: GitHubWorkItem): string { - return `${item.repoId}\u0000${item.id}` -} - -function sortedStrings(values: readonly string[] | undefined): string { - return [...(values ?? [])].sort().join('\u0000') -} - -function sortedLogins(users: readonly { login: string | null | undefined }[] | undefined): string { - return [...(users ?? [])] - .map((user) => user.login ?? '') - .sort() - .join('\u0000') -} - -function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string { - return JSON.stringify([ - item.type, - item.number, - item.title, - item.state, - item.url, - item.author, - item.branchName ?? null, - item.baseRefName ?? null, - sortedStrings(item.labels), - sortedLogins(item.assignees), - sortedLogins(item.reviewRequests), - item.reviewDecision ?? null, - item.checksSummary?.state ?? null, - item.checksSummary?.total ?? null, - item.checksSummary?.failed ?? null, - item.checksSummary?.pending ?? null, - item.mergeable ?? null, - item.autoMergeEnabled ?? null, - item.autoMergeAllowed ?? null, - item.mergeQueueRequired ?? null, - item.mergeStateStatus ?? null, - item.updatedAt - ]) -} - -function taskPageWorkItemKeyOrderSignature(items: readonly GitHubWorkItem[]): string { - return items.map(taskPageWorkItemKey).join('\u0000') -} - -function taskPageWorkItemPaginationBoundary(items: readonly GitHubWorkItem[]): string | null { - return items.at(-1)?.updatedAt ?? null -} - export function shouldReplaceTaskPageItemsAfterRefresh( currentItems: readonly GitHubWorkItem[], refreshedItems: readonly GitHubWorkItem[] @@ -273,66 +230,6 @@ export function reconcileTaskPagePagesAfterLandingRefresh( return [nextFirstPage, ...pages.slice(1)] } -function linearIssueKey(issue: LinearIssue): string { - return issue.id -} - -function linearIssueStatusSignature(issue: LinearIssue): string { - return JSON.stringify([ - issue.identifier, - issue.title, - issue.url, - issue.state.name, - issue.state.type, - issue.state.color, - issue.team.id, - issue.team.name, - issue.team.key, - sortedStrings(issue.labels), - issue.assignee?.id ?? null, - issue.assignee?.displayName ?? null, - issue.priority, - issue.updatedAt - ]) -} - -export function shouldReplaceTaskPageLinearIssuesAfterRefresh( - currentIssues: readonly LinearIssue[], - refreshedIssues: readonly LinearIssue[] -): boolean { - if (currentIssues.length !== refreshedIssues.length) { - return true - } - const currentKeys = new Set(currentIssues.map(linearIssueKey)) - for (const issue of refreshedIssues) { - if (!currentKeys.has(linearIssueKey(issue))) { - return true - } - } - return false -} - -export function reconcileTaskPageLinearIssuesAfterLandingRefresh( - currentIssues: readonly LinearIssue[], - refreshedIssues: readonly LinearIssue[] -): LinearIssue[] { - if (shouldReplaceTaskPageLinearIssuesAfterRefresh(currentIssues, refreshedIssues)) { - return [...refreshedIssues] - } - - const refreshedByKey = new Map(refreshedIssues.map((issue) => [linearIssueKey(issue), issue])) - let changed = false - const next = currentIssues.map((issue) => { - const refreshed = refreshedByKey.get(linearIssueKey(issue)) - if (!refreshed || linearIssueStatusSignature(issue) === linearIssueStatusSignature(refreshed)) { - return issue - } - changed = true - return refreshed - }) - return changed ? next : (currentIssues as LinearIssue[]) -} - export function findTaskPageDialogWorkItem( workItemsCache: WorkItemsCache, dialogWorkItemKey: TaskPageDialogWorkItemKey @@ -351,34 +248,3 @@ export function findTaskPageDialogWorkItem( } return null } - -export function findTaskPageLinearIssue( - linearIssueCache: LinearIssueCache, - linearSearchCache: LinearSearchCache, - linearListCache: LinearListCache, - linearIssueId: string | null -): LinearIssue | null { - if (!linearIssueId) { - return null - } - for (const entry of Object.values(linearIssueCache)) { - if (entry?.data?.id === linearIssueId) { - return entry.data - } - } - for (const entry of Object.values(linearSearchCache)) { - const found = entry?.data?.find((issue) => issue.id === linearIssueId) - if (found) { - return found - } - } - for (const entry of Object.values(linearListCache)) { - const found = entry?.data?.items.find((issue) => issue.id === linearIssueId) - if (found) { - return found - } - } - return null -} - -export const findTaskPageLinearDrawerIssue = findTaskPageLinearIssue diff --git a/src/renderer/src/components/task-page-linear-cache-selectors.ts b/src/renderer/src/components/task-page-linear-cache-selectors.ts new file mode 100644 index 00000000000..d13536ea93f --- /dev/null +++ b/src/renderer/src/components/task-page-linear-cache-selectors.ts @@ -0,0 +1,92 @@ +import type { CacheEntry } from '@/store/slices/github' +import type { LinearCollectionResult, LinearIssue } from '../../../shared/types' +import { sortedStrings } from './task-page-work-item-signatures' + +type LinearIssueCache = Record> +type LinearSearchCache = Record> +type LinearListCache = Record>> + +function linearIssueKey(issue: LinearIssue): string { + return issue.id +} + +function linearIssueStatusSignature(issue: LinearIssue): string { + return JSON.stringify([ + issue.identifier, + issue.title, + issue.url, + issue.state.name, + issue.state.type, + issue.state.color, + issue.team.id, + issue.team.name, + issue.team.key, + sortedStrings(issue.labels), + issue.assignee?.id ?? null, + issue.assignee?.displayName ?? null, + issue.priority, + issue.updatedAt + ]) +} + +export function shouldReplaceTaskPageLinearIssuesAfterRefresh( + currentIssues: readonly LinearIssue[], + refreshedIssues: readonly LinearIssue[] +): boolean { + if (currentIssues.length !== refreshedIssues.length) { + return true + } + const currentKeys = new Set(currentIssues.map(linearIssueKey)) + return refreshedIssues.some((issue) => !currentKeys.has(linearIssueKey(issue))) +} + +export function reconcileTaskPageLinearIssuesAfterLandingRefresh( + currentIssues: readonly LinearIssue[], + refreshedIssues: readonly LinearIssue[] +): LinearIssue[] { + if (shouldReplaceTaskPageLinearIssuesAfterRefresh(currentIssues, refreshedIssues)) { + return [...refreshedIssues] + } + const refreshedByKey = new Map(refreshedIssues.map((issue) => [linearIssueKey(issue), issue])) + let changed = false + const next = currentIssues.map((issue) => { + const refreshed = refreshedByKey.get(linearIssueKey(issue)) + if (!refreshed || linearIssueStatusSignature(issue) === linearIssueStatusSignature(refreshed)) { + return issue + } + changed = true + return refreshed + }) + return changed ? next : (currentIssues as LinearIssue[]) +} + +export function findTaskPageLinearIssue( + linearIssueCache: LinearIssueCache, + linearSearchCache: LinearSearchCache, + linearListCache: LinearListCache, + linearIssueId: string | null +): LinearIssue | null { + if (!linearIssueId) { + return null + } + for (const entry of Object.values(linearIssueCache)) { + if (entry?.data?.id === linearIssueId) { + return entry.data + } + } + for (const entry of Object.values(linearSearchCache)) { + const found = entry?.data?.find((issue) => issue.id === linearIssueId) + if (found) { + return found + } + } + for (const entry of Object.values(linearListCache)) { + const found = entry?.data?.items.find((issue) => issue.id === linearIssueId) + if (found) { + return found + } + } + return null +} + +export const findTaskPageLinearDrawerIssue = findTaskPageLinearIssue diff --git a/src/renderer/src/components/task-page-pr-check-summary.test.ts b/src/renderer/src/components/task-page-pr-check-summary.test.ts index bf774bcf721..2258d89fdbb 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.test.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.test.ts @@ -20,7 +20,8 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 0, passed: 0, failed: 0, - pending: 0 + pending: 0, + neutral: 0 }) }) @@ -36,11 +37,12 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 3, passed: 1, failed: 1, - pending: 1 + pending: 1, + neutral: 0 }) }) - it('treats neutral and skipped checks as passed for the compact PR table label', () => { + it('keeps neutral checks out of passed while skipped checks pass', () => { expect( deriveTaskPagePRCheckSummary([ check({ conclusion: 'success' }), @@ -48,11 +50,12 @@ describe('deriveTaskPagePRCheckSummary', () => { check({ conclusion: 'skipped' }) ]) ).toEqual({ - state: 'success', + state: 'neutral', total: 3, - passed: 3, + passed: 2, failed: 0, - pending: 0 + pending: 0, + neutral: 1 }) }) @@ -67,7 +70,23 @@ describe('deriveTaskPagePRCheckSummary', () => { total: 2, passed: 1, failed: 1, - pending: 0 + pending: 0, + neutral: 0 + }) + }) + + it('keeps unknown completed outcomes neutral', () => { + expect( + deriveTaskPagePRCheckSummary([ + check({ conclusion: 'future_state' as PRCheckDetail['conclusion'] }) + ]) + ).toEqual({ + state: 'neutral', + total: 1, + passed: 0, + failed: 0, + pending: 0, + neutral: 1 }) }) }) diff --git a/src/renderer/src/components/task-page-pr-check-summary.ts b/src/renderer/src/components/task-page-pr-check-summary.ts index 4f9fd2be5db..b0bc88091ff 100644 --- a/src/renderer/src/components/task-page-pr-check-summary.ts +++ b/src/renderer/src/components/task-page-pr-check-summary.ts @@ -1,4 +1,4 @@ -import type { GitHubPRCheckSummary, PRCheckDetail } from '../../../shared/types' +import type { PRCheckDetail, ProviderCheckSummary } from '../../../shared/types' function getCheckConclusion(check: PRCheckDetail): NonNullable { return check.conclusion ?? 'pending' @@ -12,19 +12,22 @@ function isPendingCheck(check: PRCheckDetail): boolean { ) } -export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): GitHubPRCheckSummary { +export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): ProviderCheckSummary { if (checks.length === 0) { - return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0 } + return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 } } let passed = 0 let failed = 0 let pending = 0 + let neutral = 0 for (const check of checks) { const conclusion = getCheckConclusion(check) - if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') { + if (conclusion === 'success' || conclusion === 'skipped') { passed += 1 + } else if (conclusion === 'neutral') { + neutral += 1 } else if ( conclusion === 'failure' || conclusion === 'timed_out' || @@ -37,15 +40,16 @@ export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): GitHubPRC } else if (isPendingCheck(check)) { pending += 1 } else { - passed += 1 + neutral += 1 } } return { - state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success', + state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : neutral > 0 ? 'neutral' : 'success', total: checks.length, passed, failed, - pending + pending, + neutral } } diff --git a/src/renderer/src/components/task-page-work-item-signatures.ts b/src/renderer/src/components/task-page-work-item-signatures.ts new file mode 100644 index 00000000000..67875e2e15e --- /dev/null +++ b/src/renderer/src/components/task-page-work-item-signatures.ts @@ -0,0 +1,54 @@ +import type { GitHubWorkItem } from '../../../shared/types' + +export function taskPageWorkItemKey(item: GitHubWorkItem): string { + return `${item.repoId}\u0000${item.id}` +} + +export function sortedStrings(values: readonly string[] | undefined): string { + return [...(values ?? [])].sort().join('\u0000') +} + +function sortedLogins(users: readonly { login: string | null | undefined }[] | undefined): string { + return [...(users ?? [])] + .map((user) => user.login ?? '') + .sort() + .join('\u0000') +} + +export function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string { + return JSON.stringify([ + item.type, + item.number, + item.title, + item.state, + item.url, + item.author, + item.branchName ?? null, + item.baseRefName ?? null, + sortedStrings(item.labels), + sortedLogins(item.assignees), + sortedLogins(item.reviewRequests), + item.reviewDecision ?? null, + item.checksSummary?.state ?? null, + item.checksSummary?.total ?? null, + item.checksSummary?.failed ?? null, + item.checksSummary?.pending ?? null, + item.checksSummary?.neutral ?? null, + item.mergeable ?? null, + item.autoMergeEnabled ?? null, + item.autoMergeAllowed ?? null, + item.mergeQueueRequired ?? null, + item.mergeStateStatus ?? null, + item.updatedAt + ]) +} + +export function taskPageWorkItemKeyOrderSignature(items: readonly GitHubWorkItem[]): string { + return items.map(taskPageWorkItemKey).join('\u0000') +} + +export function taskPageWorkItemPaginationBoundary( + items: readonly GitHubWorkItem[] +): string | null { + return items.at(-1)?.updatedAt ?? null +} diff --git a/src/renderer/src/store/slices/github-checks.test.ts b/src/renderer/src/store/slices/github-checks.test.ts index 2f295ab9328..b5c46ce6882 100644 --- a/src/renderer/src/store/slices/github-checks.test.ts +++ b/src/renderer/src/store/slices/github-checks.test.ts @@ -15,6 +15,13 @@ describe('deriveCheckStatusFromChecks', () => { ] expect(deriveCheckStatusFromChecks(checks)).toBe('failure') }) + + it('keeps an unknown terminal conclusion neutral rather than pending', () => { + const checks = [ + { name: 'future-check', status: 'completed', conclusion: 'future_state', url: null } + ] as unknown as PRCheckDetail[] + expect(deriveCheckStatusFromChecks(checks)).toBe('neutral') + }) }) describe('normalizeBranchName', () => { diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index 93a0e28d392..46bf368d757 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -1,42 +1,14 @@ import type { AppState } from '../types' -import type { PRCheckDetail, CheckStatus, GitHubOwnerRepo } from '../../../../shared/types' +import type { PRCheckDetail, GitHubOwnerRepo } from '../../../../shared/types' import { getGitHubPRCacheKey } from './github-cache-key' import { githubRepoIdentityKey } from '../../../../shared/github-repository-identity-key' +import { derivePRCheckStatus } from '../../../../shared/pr-check-status' export function normalizeBranchName(branch: string): string { return branch.replace(/^refs\/heads\//, '') } -export function deriveCheckStatusFromChecks(checks: PRCheckDetail[]): CheckStatus { - if (checks.length === 0) { - return 'neutral' - } - - let hasPending = false - - for (const check of checks) { - if ( - check.conclusion === 'failure' || - check.conclusion === 'timed_out' || - check.conclusion === 'cancelled' || - // Why: action_required (e.g. an unapproved workflow run) blocks merge until - // someone acts; treat it as needs-attention rather than a silent pass. - check.conclusion === 'action_required' - ) { - return 'failure' - } - - if ( - check.status === 'queued' || - check.status === 'in_progress' || - check.conclusion === 'pending' - ) { - hasPending = true - } - } - - return hasPending ? 'pending' : 'success' -} +export const deriveCheckStatusFromChecks = derivePRCheckStatus export function syncPRChecksStatus( state: AppState, diff --git a/src/shared/gitlab-pipeline-checks.test.ts b/src/shared/gitlab-pipeline-checks.test.ts index 673fcf3cd16..48faafc38e0 100644 --- a/src/shared/gitlab-pipeline-checks.test.ts +++ b/src/shared/gitlab-pipeline-checks.test.ts @@ -49,7 +49,7 @@ describe('gitLabPipelineJobsToPRChecks', () => { { name: 'deploy: deploy', status: 'completed', - conclusion: 'neutral', + conclusion: 'action_required', url: null }, { diff --git a/src/shared/gitlab-pipeline-checks.ts b/src/shared/gitlab-pipeline-checks.ts index efcea5ee4bf..a988967fa32 100644 --- a/src/shared/gitlab-pipeline-checks.ts +++ b/src/shared/gitlab-pipeline-checks.ts @@ -35,10 +35,8 @@ export function mapGitLabPipelineJobStatusToConclusion( if (s === 'skipped') { return 'skipped' } - // Why: manual GitLab jobs are intentionally waiting for a human trigger; - // treating them as pending would make the Checks tab look stuck forever. - if (s === 'manual') { - return 'neutral' + if (s === 'manual' || s === 'action_required') { + return 'action_required' } if ( s === 'created' || diff --git a/src/shared/gitlab-types.ts b/src/shared/gitlab-types.ts index 5d007d7f923..503f2e36055 100644 --- a/src/shared/gitlab-types.ts +++ b/src/shared/gitlab-types.ts @@ -1,5 +1,6 @@ /* GitLab-specific shared types, split from `./types` to avoid merge conflicts on upstream syncs; re-exported from `./types` for import stability. */ -import type { CheckStatus, ClassifiedError, PRConflictSummary } from './types' +import type { CheckStatus, ClassifiedError, PRConflictSummary, ProviderCheckSummary } from './types' +import type { HostedReviewDecision } from './hosted-review' // Why: flat owner/repo is inadequate — projects nest (`group/subgroup/project`) and self-hosted hosts must travel with the path for URL/glab targeting. export type GitLabProjectRef = { host: string; path: string } @@ -172,6 +173,10 @@ export type GitLabWorkItem = { repoId: string /** Exact GitLab project that produced this row — mutations/details use it instead of re-resolving the repo preference. */ projectRef?: GitLabProjectRef + checksSummary?: ProviderCheckSummary + mergeable?: MRMergeableState + reviewDecision?: HostedReviewDecision + reviewerCount?: number } export type GitLabMRFile = { diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index e7db9111f03..6fc90a39aaf 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -1,5 +1,6 @@ import type { PRCheckDetail, PRComment, PRInfo } from './types' import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' +import { derivePRCheckStatus } from './pr-check-status' export type HostedReviewFromGitHubPRInfoArgs = { pr: PRInfo @@ -32,33 +33,10 @@ function deriveChecksStatus( prChecksStatus: PRInfo['checksStatus'], checks?: PRCheckDetail[] ): PRInfo['checksStatus'] { - if (!checks || checks.length === 0) { + if (!checks) { return prChecksStatus } - const hasFailure = checks.some( - (check) => - check.conclusion === 'failure' || - check.conclusion === 'timed_out' || - check.conclusion === 'cancelled' || - // Why: action_required (e.g. a workflow awaiting approval) blocks merge; - // treat it as failure so the review queue doesn't report a clean PR. - check.conclusion === 'action_required' - ) - if (hasFailure) { - return 'failure' - } - const hasPending = checks.some( - (check) => - check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending' - ) - if (hasPending) { - return 'pending' - } - const hasSuccess = checks.some((check) => check.conclusion === 'success') - if (hasSuccess) { - return 'success' - } - return 'neutral' + return derivePRCheckStatus(checks) } export function hostedReviewSummaryFromGitHubPRInfo( diff --git a/src/shared/hosted-review-gitlab.ts b/src/shared/hosted-review-gitlab.ts index 401c6191714..3253ee9ff16 100644 --- a/src/shared/hosted-review-gitlab.ts +++ b/src/shared/hosted-review-gitlab.ts @@ -1,5 +1,6 @@ import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' import type { PRCheckDetail, PRComment } from './types' +import { derivePRCheckStatus } from './pr-check-status' export type HostedReviewFromGitLabInfoArgs = { review: HostedReviewInfo & { provider: 'gitlab' } @@ -58,27 +59,10 @@ function deriveChecksStatus( reviewStatus: HostedReviewInfo['status'], checks?: PRCheckDetail[] ): HostedReviewInfo['status'] { - if (!checks || checks.length === 0) { + if (!checks) { return reviewStatus } - const hasFailure = checks.some( - (check) => check.conclusion === 'failure' || check.conclusion === 'timed_out' - ) - if (hasFailure) { - return 'failure' - } - const hasPending = checks.some( - (check) => - check.status !== 'completed' || check.conclusion === null || check.conclusion === 'pending' - ) - if (hasPending) { - return 'pending' - } - const hasSuccess = checks.some((check) => check.conclusion === 'success') - if (hasSuccess) { - return 'success' - } - return 'neutral' + return derivePRCheckStatus(checks) } export function hostedReviewSummaryFromGitLabInfo( diff --git a/src/shared/pr-check-severity-order.test.ts b/src/shared/pr-check-severity-order.test.ts new file mode 100644 index 00000000000..126ab012723 --- /dev/null +++ b/src/shared/pr-check-severity-order.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { PRCheckDetail } from './types' +import { getCheckSeverityRank, sortChecksBySeverity } from './pr-check-severity-order' +import { mapGitLabPipelineJobStatusToConclusion } from './gitlab-pipeline-checks' + +const check = (name: string, conclusion: string | null) => + ({ name, conclusion }) as Pick + +describe('PR check severity order', () => { + it('keeps passing checks above the skipped and neutral noise', () => { + const sorted = sortChecksBySeverity([ + check('daily-cleanup', 'skipped'), + check('Test Results', 'success'), + check('build-admin', 'skipped'), + check('advisory', 'neutral'), + check('Validate Configs', 'success') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'Test Results', + 'Validate Configs', + 'advisory', + 'daily-cleanup', + 'build-admin' + ]) + }) + + it('orders failures, then in-flight work, then successes', () => { + const sorted = sortChecksBySeverity([ + check('success', 'success'), + check('skipped', 'skipped'), + check('pending', null), + check('cancelled', 'cancelled'), + check('failure', 'failure') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'failure', + 'cancelled', + 'pending', + 'success', + 'skipped' + ]) + }) + + it('covers every known conclusion and preserves input order within equal ranks', () => { + const sorted = sortChecksBySeverity([ + check('success-a', 'success'), + check('unknown', 'future_state'), + check('skipped', 'skipped'), + check('action-required', 'action_required'), + check('pending', 'pending'), + check('timed-out', 'timed_out'), + check('neutral', 'neutral'), + check('cancelled', 'cancelled'), + check('failure', 'failure'), + check('success-b', 'success'), + check('failure-b', 'failure-b') + ]) + + expect(sorted.map((c) => c.name)).toEqual([ + 'action-required', + 'timed-out', + 'failure', + 'cancelled', + 'pending', + 'success-a', + 'success-b', + 'neutral', + 'skipped', + 'unknown', + 'failure-b' + ]) + }) + + it('sinks unknown conclusions below every known state', () => { + expect(getCheckSeverityRank('stale_from_a_future_github')).toBeGreaterThan( + getCheckSeverityRank('skipped') + ) + }) + + // Why: an object-literal rank table would resolve these off Object.prototype and + // return a function, turning the comparator into NaN and scrambling the list. + it('treats prototype property names as unknown conclusions', () => { + for (const inherited of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) { + expect(getCheckSeverityRank(inherited)).toBe( + getCheckSeverityRank('stale_from_a_future_github') + ) + } + + const sorted = sortChecksBySeverity([ + check('inherited', 'constructor'), + check('failure', 'failure'), + check('success', 'success') + ]) + expect(sorted.map((c) => c.name)).toEqual(['failure', 'success', 'inherited']) + }) + + it('treats a missing conclusion as pending', () => { + expect(getCheckSeverityRank(null)).toBe(getCheckSeverityRank('pending')) + expect(getCheckSeverityRank(undefined)).toBe(getCheckSeverityRank('pending')) + }) + + it('leaves the input array untouched', () => { + const checks = [check('success', 'success'), check('failure', 'failure')] + sortChecksBySeverity(checks) + expect(checks.map((c) => c.name)).toEqual(['success', 'failure']) + }) + + it('orders normalized provider states with stable ties', () => { + const checks = [ + // Preserve an unrecognized provider value so it remains visibly unknown. + check('future', 'future_state'), + check('manual', mapGitLabPipelineJobStatusToConclusion('manual')), + check('pass-a', 'success'), + check('pass-b', 'success') + ] + + expect(sortChecksBySeverity(checks).map((item) => item.name)).toEqual([ + 'manual', + 'pass-a', + 'pass-b', + 'future' + ]) + }) +}) diff --git a/src/shared/pr-check-severity-order.ts b/src/shared/pr-check-severity-order.ts new file mode 100644 index 00000000000..0bf9effba63 --- /dev/null +++ b/src/shared/pr-check-severity-order.ts @@ -0,0 +1,36 @@ +import type { PRCheckDetail } from './types' + +// Why: `neutral`/`skipped` carry no signal, so they sink below `success` — otherwise a +// wall of skipped jobs buries the passing checks a reviewer actually reads. +// A Map, not an object: conclusions arrive from provider payloads, and an object +// would resolve `constructor`/`toString` off the prototype into a non-number rank. +const CHECK_SEVERITY_RANK = new Map([ + ['failure', 0], + ['timed_out', 0], + ['action_required', 0], + ['cancelled', 1], + ['pending', 2], + ['success', 3], + ['neutral', 4], + ['skipped', 5] +]) + +// Why: an unrecognized conclusion sinks to the bottom instead of masquerading as `neutral`. +const UNKNOWN_CHECK_RANK = 6 + +export function getCheckSeverityRank(conclusion: string | null | undefined): number { + return CHECK_SEVERITY_RANK.get(conclusion ?? 'pending') ?? UNKNOWN_CHECK_RANK +} + +export function sortChecksBySeverity>( + checks: readonly T[] +): T[] { + return checks + .map((check, index) => ({ check, index })) + .sort( + (a, b) => + getCheckSeverityRank(a.check.conclusion) - getCheckSeverityRank(b.check.conclusion) || + a.index - b.index + ) + .map(({ check }) => check) +} diff --git a/src/shared/pr-check-status.test.ts b/src/shared/pr-check-status.test.ts new file mode 100644 index 00000000000..fc8c8415428 --- /dev/null +++ b/src/shared/pr-check-status.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { derivePRCheckStatus, derivePRCheckStatusFromRollup } from './pr-check-status' +import type { PRCheckDetail } from './types' + +const check = ( + status: PRCheckDetail['status'], + conclusion: PRCheckDetail['conclusion'] +): PRCheckDetail => ({ name: 'ci', status, conclusion, url: null }) + +describe('provider-neutral check status', () => { + it('keeps explicit nonterminal and missing-conclusion checks pending', () => { + expect(derivePRCheckStatus([check('queued', null)])).toBe('pending') + expect(derivePRCheckStatus([check('in_progress', null)])).toBe('pending') + expect(derivePRCheckStatus([check('completed', 'pending')])).toBe('pending') + }) + + it('keeps completed unknown conclusions neutral while preserving attention states', () => { + expect(derivePRCheckStatus([check('completed', null)])).toBe('neutral') + expect( + derivePRCheckStatus([check('completed', 'future_state' as PRCheckDetail['conclusion'])]) + ).toBe('neutral') + expect(derivePRCheckStatus([check('completed', 'action_required')])).toBe('failure') + }) + + it('normalizes GitHub-style rollups without turning malformed data into success', () => { + expect(derivePRCheckStatusFromRollup([{ status: 'IN_PROGRESS', conclusion: null }])).toBe( + 'pending' + ) + expect( + derivePRCheckStatusFromRollup([{ status: 'COMPLETED', conclusion: 'future_state' }]) + ).toBe('neutral') + expect(derivePRCheckStatusFromRollup([{}])).toBe('neutral') + expect(derivePRCheckStatusFromRollup([{ state: 'PENDING' }])).toBe('pending') + expect(derivePRCheckStatusFromRollup([{ state: 'ERROR' }])).toBe('failure') + }) +}) diff --git a/src/shared/pr-check-status.ts b/src/shared/pr-check-status.ts new file mode 100644 index 00000000000..184b8da5a62 --- /dev/null +++ b/src/shared/pr-check-status.ts @@ -0,0 +1,74 @@ +import type { CheckStatus, PRCheckDetail } from './types' + +/** Derives the review status from the normalized check contract. */ +export function derivePRCheckStatus(checks: readonly PRCheckDetail[]): CheckStatus { + if (checks.length === 0) { + return 'neutral' + } + + let hasPending = false + let hasSuccess = false + for (const check of checks) { + if ( + check.conclusion === 'failure' || + check.conclusion === 'timed_out' || + check.conclusion === 'cancelled' || + check.conclusion === 'action_required' + ) { + return 'failure' + } + if ( + check.status === 'queued' || + check.status === 'in_progress' || + check.conclusion === 'pending' + ) { + hasPending = true + } + if (check.conclusion === 'success') { + hasSuccess = true + } + } + + if (hasPending) { + return 'pending' + } + return hasSuccess ? 'success' : 'neutral' +} + +type RawCheckRollup = { status?: unknown; conclusion?: unknown; state?: unknown } + +function normalizeRollupCheck(raw: RawCheckRollup, index: number): PRCheckDetail { + const status = String(raw.status ?? '').toLowerCase() + const state = String(raw.state ?? '').toLowerCase() + const conclusion = String(raw.conclusion ?? '').toLowerCase() + const normalizedConclusion = + conclusion || + (state === 'failure' || state === 'error' ? 'failure' : state === 'success' ? 'success' : '') + const isPending = + status === 'queued' || + status === 'in_progress' || + status === 'pending' || + state === 'pending' || + conclusion === 'pending' + + return { + name: `check-${index}`, + status: isPending ? (status === 'in_progress' ? 'in_progress' : 'queued') : 'completed', + conclusion: (isPending + ? 'pending' + : normalizedConclusion || null) as PRCheckDetail['conclusion'], + url: null + } +} + +/** Derives status from provider rollups while retaining status/conclusion semantics. */ +export function derivePRCheckStatusFromRollup(rollup: unknown): CheckStatus { + if (!Array.isArray(rollup) || rollup.length === 0) { + return 'neutral' + } + return derivePRCheckStatus( + rollup.map((raw, index) => + normalizeRollupCheck(raw && typeof raw === 'object' ? (raw as RawCheckRollup) : {}, index) + ) + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index cbbfa0d8776..a4018f8ab86 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1542,12 +1542,13 @@ export type GitHubAssignableUser = { avatarUrl: string } -export type GitHubPRCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'none' +export type ProviderCheckSummary = { + state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' total: number passed: number failed: number pending: number + neutral: number } export type GitHubPRReviewSummary = { @@ -1586,7 +1587,7 @@ export type GitHubWorkItem = { reviewRequests?: GitHubAssignableUser[] latestReviews?: GitHubPRReviewSummary[] assignees?: GitHubAssignableUser[] - checksSummary?: GitHubPRCheckSummary + checksSummary?: ProviderCheckSummary mergeable?: PRMergeableState autoMergeEnabled?: boolean autoMergeAllowed?: boolean | null From 7ba433209cbdb6d95d1bfa8a3e71a051174524d0 Mon Sep 17 00:00:00 2001 From: TaeHwan Jung Date: Fri, 31 Jul 2026 05:30:07 +0900 Subject: [PATCH 002/123] fix(editor): map .cts/.mts to the typescript language id (#11294) * fix(editor): map .cts/.mts to the typescript language id The comment above EXT_TO_LANGUAGE already documents that Monaco maps .tsx/.cts/.mts onto the typescript language id, but only .tsx was in the table, so .cts/.mts files opened as plaintext. * fix(mobile): map cts and mts to typescript --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- mobile/src/session/mobile-file-language.ts | 2 ++ mobile/src/session/mobile-file-syntax.test.ts | 3 +++ src/renderer/src/lib/language-detect.test.ts | 12 ++++++++++++ src/renderer/src/lib/language-detect.ts | 2 ++ 4 files changed, 19 insertions(+) diff --git a/mobile/src/session/mobile-file-language.ts b/mobile/src/session/mobile-file-language.ts index 30e4656c0a2..03b368c9411 100644 --- a/mobile/src/session/mobile-file-language.ts +++ b/mobile/src/session/mobile-file-language.ts @@ -10,6 +10,8 @@ function extname(filePath: string): string { const EXT_TO_LANGUAGE: Record = { '.ts': 'typescript', '.tsx': 'typescript', + '.cts': 'typescript', + '.mts': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', diff --git a/mobile/src/session/mobile-file-syntax.test.ts b/mobile/src/session/mobile-file-syntax.test.ts index 59509742539..f01a240e10f 100644 --- a/mobile/src/session/mobile-file-syntax.test.ts +++ b/mobile/src/session/mobile-file-syntax.test.ts @@ -11,9 +11,12 @@ import type { MobileDiffLine } from './mobile-diff-lines' describe('mobile file syntax highlighting', () => { it('detects common source languages from file paths', () => { expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript') + expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript') + expect(detectMobileFileLanguage('C:\\repo\\scripts\\postinstall.CTS')).toBe('typescript') expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell') expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile') expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript') + expect(resolveMobileSyntaxLanguage('worktrees/feature/build.cts')).toBe('typescript') expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext') }) diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index 0bb81a5c7f8..8fd253c977a 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -47,6 +47,18 @@ describe('detectLanguage', () => { expect(detectLanguage('C:\\Users\\alice\\.codex\\LOG.JSONL')).toBe('jsonl') }) + it('maps .cts/.mts files to the Monaco built-in typescript language id (case-insensitive)', () => { + expect(detectLanguage('config/vitest.config.mts')).toBe('typescript') + expect(detectLanguage('scripts/postinstall.cts')).toBe('typescript') + expect(detectLanguage('types/global.d.mts')).toBe('typescript') + expect(detectLanguage('C:\\repo\\config\\BUILD.MTS')).toBe('typescript') + }) + + it('keeps .mjs/.cjs on the Monaco built-in javascript language id', () => { + expect(detectLanguage('scripts/build.mjs')).toBe('javascript') + expect(detectLanguage('scripts/legacy.cjs')).toBe('javascript') + }) + it('keeps .json/.jsonc on the built-in json language and unknown on plaintext', () => { expect(detectLanguage('config/settings.json')).toBe('json') expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index fabf6c31460..fc918dd4323 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -14,6 +14,8 @@ const EXT_TO_LANGUAGE: Record = { // is what gives .tsx/.jsx files syntax highlighting in the editor. '.ts': 'typescript', '.tsx': 'typescript', + '.cts': 'typescript', + '.mts': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', From 89a9d4fbda99de5a93086c2948b844faabb793e1 Mon Sep 17 00:00:00 2001 From: Appcaster Date: Fri, 31 Jul 2026 05:30:28 +0900 Subject: [PATCH 003/123] fix(mobile): recover when half-open sockets omit close events (#11368) * fix(mobile): recover half-open RPC sockets * test(mobile): assert reconnect attempt reset * fix(mobile): coalesce half-open recovery probes --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- mobile/src/transport/rpc-client.test.ts | 41 +++++++++++++++++++++++++ mobile/src/transport/rpc-client.ts | 12 +++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 0b6d5c84aae..67e920ff1af 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -707,6 +707,47 @@ describe('mobile rpc-client connection timeout', () => { client.close() }) + it('reconnects when the half-open socket omits its close callback', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + openAndAuthenticate(socket) + socket.emitCloseOnClose = false + + client.notifyForeground() + await vi.advanceTimersByTimeAsync(8_000) + + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + socket.onclose?.() + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + openAndAuthenticate(mockSockets[1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) + + await vi.advanceTimersByTimeAsync(1_000) + expect(mockSockets).toHaveLength(2) + + client.close() + }) + + it('coalesces repeated foreground probes while one probe is pending', async () => { + const client = connectAuthenticated().client + const socket = mockSockets[0]! + client.notifyForeground() + client.notifyForeground() + client.notifyForeground() + expect(sentRequests(socket, 'status.get')).toHaveLength(1) + await vi.advanceTimersByTimeAsync(8_000) + expect(socket.close).toHaveBeenCalledTimes(1) + expect(client.getState()).toBe('reconnecting') + + await vi.advanceTimersByTimeAsync(500) + expect(mockSockets).toHaveLength(2) + client.close() + }) it('keeps a healthy connection when the foreground probe is answered', async () => { const { client, socket } = connectAuthenticated() diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 1f026e550c4..30f6d7a426f 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -161,6 +161,7 @@ export function connect( let connectTimer: ReturnType | null = null let handshakeTimer: ReturnType | null = null let activityProbeTimer: ReturnType | null = null + let activityProbeInFlight = false let intentionallyClosed = false // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair. let authRejectionCount = 0 @@ -768,15 +769,17 @@ export function connect( // Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects. function runActivityProbe() { - if (state !== 'connected' || !ws) { + if (state !== 'connected' || !ws || activityProbeInFlight) { return } + activityProbeInFlight = true const probeWs = ws const id = nextId() const probeInboundSequence = inboundSequence let timedOut = false const timeout = setTimeout(() => { timedOut = true + activityProbeInFlight = false pending.delete(id) if (inboundSequence > probeInboundSequence) { return @@ -785,6 +788,10 @@ export function connect( // Why: stale probe timers must not close a replacement socket. if (probeWs === ws && probeWs.readyState === WebSocket.OPEN) { probeWs.close() + // Why: React Native can omit onclose for a wedged iOS transport. + if (probeWs === ws) { + handleSocketClosed(probeWs, { timedOut: true }) + } } }, 8_000) pending.set(id, { @@ -792,16 +799,19 @@ export function connect( if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) }, reject: () => { if (timedOut) { return } + activityProbeInFlight = false clearTimeout(timeout) } }) if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) { + activityProbeInFlight = false clearTimeout(timeout) pending.delete(id) } From 5cc21ade6a84acb6a0ca23c287a7b634e8f3ca27 Mon Sep 17 00:00:00 2001 From: ye4241 <6803102+ye4241@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:47:44 +0800 Subject: [PATCH 004/123] fix(mobile): render the terminal caret for main-buffer TUIs (Claude Code) (#11387) * fix(mobile): render the terminal caret for main-buffer TUIs The mobile WebView never flipped xterm's isCursorInitialized, which both renderers check before they ever read cursorStyle/cursorInactiveStyle. The native TextInput owns keyboard focus and xterm's textarea is inert, so the focus and keydown paths never fire, leaving DECSET 1049 as the only way to flip it. Alt-screen TUIs got a caret as a side effect; Claude Code, which redraws its composer in the main buffer, never did. Set showCursorImmediately so the caret does not depend on focus, and switch cursorInactiveStyle to block: mobile is permanently unfocused, so that option is what renders, and a bar is dpr device px wide and disappears under the fit scale() the WebView applies. Refs #8313, #7093 * test(mobile): prove main-buffer caret rendering * test(mobile): calibrate terminal listener cleanup * test(mobile): keep caret oracle teardown assertion-free --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../terminal-caret-rendering-oracle.test.ts | 194 ++++++++++++++++++ mobile/src/terminal/terminal-webview-html.ts | 18 +- .../terminal-webview-init-surface.test.ts | 4 +- 3 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 mobile/src/terminal/terminal-caret-rendering-oracle.test.ts diff --git a/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts b/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts new file mode 100644 index 00000000000..d2cf51df3b8 --- /dev/null +++ b/mobile/src/terminal/terminal-caret-rendering-oracle.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html' + +type CursorCoreService = { + isCursorHidden: boolean + isCursorInitialized: boolean +} + +type ListenerOwner = Pick + +function cursorCoreService(terminal: Terminal): CursorCoreService { + const core = (terminal as unknown as { _core?: { coreService?: CursorCoreService } })._core + expect(core, 'xterm private _core compatibility').toBeDefined() + expect(core?.coreService, 'xterm private coreService compatibility').toBeDefined() + expect(typeof core?.coreService?.isCursorHidden).toBe('boolean') + expect(typeof core?.coreService?.isCursorInitialized).toBe('boolean') + return core!.coreService! +} + +function listenerOwner(target: EventTarget): ListenerOwner { + let owner = target as ListenerOwner | null + while (owner) { + if (Object.hasOwn(owner, 'addEventListener') && Object.hasOwn(owner, 'removeEventListener')) { + return owner + } + owner = Object.getPrototypeOf(owner) as ListenerOwner | null + } + throw new Error(`No event-listener owner for ${target.constructor.name}`) +} + +function eventListenerOwners(): ListenerOwner[] { + const targets: EventTarget[] = [ + document, + document.defaultView!, + document.documentElement, + document.body, + document.createElement('div'), + document.createElement('textarea'), + document.createElement('canvas') + ] + return [...new Set(targets.map(listenerOwner))] +} + +function trackEventListenerCleanup(): () => { + added: number + removed: number + unreleased: string[] +} { + const registrations: Array<{ + capture: boolean + listener: EventListenerOrEventListenerObject + removed: boolean + target: EventTarget + type: string + }> = [] + const capture = (options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean => + typeof options === 'boolean' ? options : (options?.capture ?? false) + const activeRegistration = ( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | EventListenerOptions + ) => + registrations.find( + (entry) => + !entry.removed && + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture(options) + ) + + for (const owner of eventListenerOwners()) { + const addEventListener = owner.addEventListener + const removeEventListener = owner.removeEventListener + vi.spyOn(owner, 'addEventListener').mockImplementation(function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ) { + addEventListener.call(this, type, listener, options) + if (!activeRegistration(this, type, listener, options)) { + registrations.push({ + capture: capture(options), + listener, + removed: false, + target: this, + type + }) + } + }) + vi.spyOn(owner, 'removeEventListener').mockImplementation(function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ) { + removeEventListener.call(this, type, listener, options) + const registration = activeRegistration(this, type, listener, options) + if (registration) { + registration.removed = true + } + }) + } + + return () => ({ + added: registrations.length, + removed: registrations.filter((registration) => registration.removed).length, + unreleased: registrations + .filter((registration) => !registration.removed) + .map((registration) => registration.type) + }) +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe('xterm caret rendering oracle', () => { + beforeEach(() => { + vi.stubGlobal( + 'OffscreenCanvas', + class { + getContext(): Pick { + return { font: '', measureText: () => ({ width: 8 }) as TextMetrics } + } + } + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('renders and hides an unfocused main-buffer caret', async () => { + const listenerCleanup = trackEventListenerCleanup() + const options: ITerminalOptions & ITerminalInitOnlyOptions = MOBILE_TERMINAL_CARET_OPTIONS + const terminal = new Terminal(options) + const container = document.createElement('div') + document.body.append(container) + + try { + terminal.open(container) + await write(terminal, '\x1b[?25h\x1b[2K\x1b[1G> hello\x1b[?2004h\x1b[1;3H') + terminal.refresh(0, terminal.rows - 1) + await vi.waitFor(() => expect(container.textContent).toContain('hello')) + const coreService = cursorCoreService(terminal) + expect({ + initialized: coreService.isCursorInitialized, + rendered: container.querySelector('.xterm-cursor') !== null + }).toEqual({ initialized: true, rendered: true }) + + await write(terminal, '\x1b[?25l') + terminal.refresh(0, terminal.rows - 1) + await vi.waitFor(() => expect(container.querySelector('.xterm-cursor')).toBeNull()) + expect({ + hidden: coreService.isCursorHidden, + rendered: container.querySelector('.xterm-cursor') !== null + }).toEqual({ hidden: true, rendered: false }) + } finally { + // Why: teardown only — an assertion here would mask the body failure and strand the container. + terminal.dispose() + container.remove() + } + + expect(container.querySelector('.xterm')).toBeNull() + const cleanup = listenerCleanup() + expect(cleanup.added).toBeGreaterThan(0) + expect(cleanup.removed).toBe(cleanup.added) + expect(cleanup.unreleased).toEqual([]) + }) + + it('releases listeners across 25 terminal lifecycles', () => { + const listenerCleanup = trackEventListenerCleanup() + for (let cycle = 0; cycle < 25; cycle += 1) { + const terminal = new Terminal(MOBILE_TERMINAL_CARET_OPTIONS) + const container = document.createElement('div') + document.body.append(container) + terminal.open(container) + terminal.dispose() + expect(container.querySelector('.xterm')).toBeNull() + container.remove() + } + + const cleanup = listenerCleanup() + expect(cleanup.added).toBeGreaterThan(0) + expect(cleanup.removed).toBe(cleanup.added) + expect(cleanup.unreleased).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 11579955926..e8409cb3bee 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -38,6 +38,13 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { brightWhite: '#c0caf5' } +export const MOBILE_TERMINAL_CARET_OPTIONS = { + cursorBlink: false, + cursorStyle: 'bar', + showCursorImmediately: true, + cursorInactiveStyle: 'block' +} as const + // Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export const XTERM_HTML = ` @@ -726,11 +733,12 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // Why: xterm suppresses parser-generated query replies when disableStdin // is true. Native accepts only validated reply grammars from onData. disableStdin: false, - cursorBlink: false, - cursorStyle: 'bar', - // Why: native TextInput owns mobile keyboard focus, so xterm stays inactive. - // Match its active bar while still honoring application cursor-hide sequences. - cursorInactiveStyle: 'bar', + cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink}, + cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)}, + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately}, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)}, convertEol: false, allowProposedApi: true }); diff --git a/mobile/src/terminal/terminal-webview-init-surface.test.ts b/mobile/src/terminal/terminal-webview-init-surface.test.ts index 9aa23e1fc53..fdef4f5b4df 100644 --- a/mobile/src/terminal/terminal-webview-init-surface.test.ts +++ b/mobile/src/terminal/terminal-webview-init-surface.test.ts @@ -18,6 +18,7 @@ type TerminalStub = ReturnType type TerminalOptions = { cursorInactiveStyle?: string cursorStyle?: string + showCursorImmediately?: boolean } type RegisteredWindowListener = { listener: EventListenerOrEventListenerObject @@ -146,7 +147,8 @@ describe('terminal WebView init surface replacement', () => { for (const options of terminalOptions) { expect(options).toMatchObject({ cursorStyle: 'bar', - cursorInactiveStyle: 'bar' + cursorInactiveStyle: 'block', + showCursorImmediately: true }) } }) From 53430e34d6c8141c185651ed4871ee7ccf8d2413 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:59:50 -0700 Subject: [PATCH 005/123] fix(test): stabilize system SSH transport integration (#11597) * fix(test): stabilize system SSH transport integration * fix(lint): extract terminal display mode predicate * fix(test): exercise fake relay socket bridge --- .../app/h/[hostId]/session/[worktreeId].tsx | 15 ++---- .../mobile-session-route-helpers.test.ts | 22 ++++++++ .../session/mobile-session-route-helpers.ts | 11 ++++ .../ssh-system-transport.integration.test.ts | 54 ++++++++++--------- 4 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 mobile/src/session/mobile-session-route-helpers.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5b749a41bd8..9deac34cd36 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -244,6 +244,7 @@ import { getActiveTabIdForHandle, isFileExistsErrorMessage, isGestureMouseTrackingMode, + isTerminalPhoneDisplayMode, MOBILE_SESSION_STATUS_LABELS, TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS, @@ -4167,14 +4168,6 @@ export default function SessionScreen() { }) const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions) - const isPhoneMode = (handle: string | null): boolean => { - if (!handle) { - return false - } - const mode = terminalModes.get(handle) - return mode === 'auto' || mode === 'phone' || mode === undefined - } - const visibleTabs: MobileSessionTab[] = sessionTabs const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null @@ -4832,12 +4825,12 @@ export default function SessionScreen() { } }} accessibilityLabel={ - isPhoneMode(activeHandle) + isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? 'Switch to desktop mode' : 'Switch to phone mode' } > - {isPhoneMode(activeHandle) ? ( + {isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? ( setActionTarget(null), onToggleChat: toggleTabChatView, - isPhoneMode, + isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes), onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), onRename: setRenameTarget, onClear: (target) => void handleClearTerminal(target), diff --git a/mobile/src/session/mobile-session-route-helpers.test.ts b/mobile/src/session/mobile-session-route-helpers.test.ts new file mode 100644 index 00000000000..6ae7a930844 --- /dev/null +++ b/mobile/src/session/mobile-session-route-helpers.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers' + +describe('isTerminalPhoneDisplayMode', () => { + it('uses phone mode for automatic, phone, and unreported terminals', () => { + const modes = new Map([ + ['auto', 'auto'], + ['phone', 'phone'] + ] as const) + + expect(isTerminalPhoneDisplayMode('auto', modes)).toBe(true) + expect(isTerminalPhoneDisplayMode('phone', modes)).toBe(true) + expect(isTerminalPhoneDisplayMode('missing', modes)).toBe(true) + }) + + it('rejects absent handles and desktop terminals', () => { + const modes = new Map([['desktop', 'desktop']] as const) + + expect(isTerminalPhoneDisplayMode(null, modes)).toBe(false) + expect(isTerminalPhoneDisplayMode('desktop', modes)).toBe(false) + }) +}) diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index 52b277bd6b8..413e1316beb 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -34,6 +34,17 @@ export function isGestureMouseTrackingMode( return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any' } +export function isTerminalPhoneDisplayMode( + handle: string | null, + terminalModes: ReadonlyMap +): boolean { + if (!handle) { + return false + } + const mode = terminalModes.get(handle) + return mode === undefined || mode === 'auto' || mode === 'phone' +} + export function getActiveTabIdForHandle( tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>, terminalHandle: string | null diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 33e5fb3c80d..e82eea80409 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -54,6 +54,8 @@ exec /bin/sh -c "$cmd" } function writeFakeRelay(dir: string): void { + writeFileSync(join(dir, 'relay-watcher.js'), '') + writeFileSync(join(dir, 'managed-hook-runtime.js'), '') writeFileSync( join(dir, 'relay.js'), ` @@ -72,7 +74,8 @@ function encode(msg) { return Buffer.concat([header, payload]); } -function serve(socket) { +function serve(socket, onResolved) { + socket.on('error', () => {}); socket.write(sentinel); let buffer = Buffer.alloc(0); socket.on('data', (chunk) => { @@ -86,7 +89,10 @@ function serve(socket) { if (type !== 1) continue; const message = JSON.parse(payload.toString('utf8')); if (message.method === 'session.resolveHome') { - socket.write(encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME })); + socket.write( + encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME }), + onResolved + ); } } }); @@ -94,27 +100,18 @@ function serve(socket) { if (process.argv.includes('--detached')) { try { fs.unlinkSync(sockPath); } catch {} - const server = net.createServer(serve); - server.listen(sockPath); - setTimeout(() => process.exit(0), 20000).unref(); -} else if (process.argv.includes('--connect')) { - process.stdout.write(sentinel); - let buffer = Buffer.alloc(0); - process.stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 13) { - const type = buffer[0]; - const length = buffer.readUInt32BE(9); - if (buffer.length < 13 + length) return; - const payload = buffer.subarray(13, 13 + length); - buffer = buffer.subarray(13 + length); - if (type !== 1) continue; - const message = JSON.parse(payload.toString('utf8')); - if (message.method === 'session.resolveHome') { - process.stdout.write(encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME })); - } - } + const server = net.createServer((socket) => { + serve(socket, () => server.close()); }); + server.listen(sockPath); +} else if (process.argv.includes('--connect')) { + const socket = net.createConnection(sockPath); + socket.on('error', (error) => { + process.stderr.write(error.message); + process.exit(1); + }); + process.stdin.pipe(socket); + socket.pipe(process.stdout); } ` ) @@ -137,12 +134,15 @@ function createRelayTree(root: string, remoteHome: string): void { } const remoteDir = join(remoteHome, '.orca-remote', `relay-${RELAY_VERSION}`) - mkdirSync(join(remoteDir, 'node_modules', 'node-pty'), { recursive: true }) + mkdirSync(join(remoteDir, 'node_modules', 'node-pty', 'lib'), { recursive: true }) mkdirSync(join(remoteDir, 'node_modules', '@parcel', 'watcher'), { recursive: true }) writeFileSync(join(remoteDir, 'node_modules', 'node-pty', 'index.js'), '') + writeFileSync( + join(remoteDir, 'node_modules', 'node-pty', 'lib', 'utils.js'), + 'exports.loadNativeModule = () => ({})\n' + ) writeFileSync(join(remoteDir, 'node_modules', '@parcel', 'watcher', 'index.js'), '') writeFileSync(join(remoteDir, '.install-complete'), '') - writeFileSync(join(remoteDir, 'managed-hook-runtime.js'), '') writeFakeRelay(remoteDir) } @@ -205,10 +205,14 @@ describe('system SSH transport integration', () => { 'deploys and speaks relay RPC over a system ssh process for ProxyUseFdpass targets', async () => { const conn = new SshConnection(makeTarget(), { onStateChange: vi.fn() }) + const onProgress = vi.fn() await conn.connect() expect(conn.usesSystemSshTransport()).toBe(true) - const result = await deployAndLaunchRelay(conn, undefined, 60, makeTarget().id) + const result = await deployAndLaunchRelay(conn, onProgress, 60, makeTarget().id) + expect(onProgress).toHaveBeenCalledWith('Starting relay...') + expect(onProgress).not.toHaveBeenCalledWith('Uploading relay...') + expect(onProgress).not.toHaveBeenCalledWith('Installing native dependencies...') const mux = new SshChannelMultiplexer(result.transport) try { await expect(mux.request('session.resolveHome', { path: '~' })).resolves.toBe( From 5165cd1e195e952bd95aac4a8c94b95d6822b5c9 Mon Sep 17 00:00:00 2001 From: Aleksandar Mirilovic Date: Thu, 30 Jul 2026 23:17:26 +0200 Subject: [PATCH 006/123] fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) (#11351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) The browser pane's renderer-path Find handler is a window-global capture-phase keydown listener, but it armed on `isActive` (the active tab within its own group) rather than on whether its split holds focus. In a terminal+browser split, the browser was therefore `isActive` even while the terminal held keyboard focus, so it swallowed Cmd/Ctrl+F and opened find-in-page in the browser instead of find-in-terminal. Thread a focused-split signal (`isFocused`) from BrowserPaneOverlayLayer — derived from `activeGroupIdByWorktree` — down to the Find handler and gate the listener on it. This mirrors how terminal leaves already gate global shortcuts via `focusedGroupId` in TabGroupSplitLayout. Floating browser panels omit the prop and fall back to `isActive`, preserving their behavior. The IPC path (webview guest focused) is unchanged; it only fires when the guest genuinely has focus. Not platform-specific: the chord resolves through `keybindingMatchesAction` (Mod -> metaKey on macOS, ctrlKey elsewhere), so the same path is fixed on macOS, Linux, and Windows. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(browser): preserve Find before split focus settles * fix(browser): handle stale focused split IDs * fix(browser): route guest Find to source page * test(browser): wait for split address bar * test(browser): focus split before Find routing --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/browser/browser-guest-ui.test.ts | 30 ++ src/main/browser/browser-guest-ui.ts | 16 +- src/main/browser/browser-manager.ts | 3 +- src/preload/api-types.ts | 3 +- .../browser-find-subscriptions.test.ts | 59 ++++ src/preload/browser-find-subscriptions.ts | 51 +++ src/preload/index.ts | 12 +- .../components/browser-pane/BrowserPane.tsx | 46 ++- .../BrowserPaneOverlayLayer.test.tsx | 96 +++++- .../browser-pane/BrowserPaneOverlayLayer.tsx | 35 ++- src/shared/browser-find-source.ts | 17 + src/shared/keybindings.test.ts | 19 ++ tests/e2e/browser-split-find-shortcut.spec.ts | 296 ++++++++++++++++++ 13 files changed, 660 insertions(+), 23 deletions(-) create mode 100644 src/preload/browser-find-subscriptions.test.ts create mode 100644 src/preload/browser-find-subscriptions.ts create mode 100644 src/shared/browser-find-source.ts create mode 100644 tests/e2e/browser-split-find-shortcut.spec.ts diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index b69ec9998e6..946ed38a7ef 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -570,6 +570,36 @@ describe('setupGuestShortcutForwarding', () => { expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:browserHistoryNavigate', 'forward') }) + it('forwards browser Find with its registered page and workspace owner', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer(), + resolveWorkspaceId: () => 'workspace-1' + }) + + const preventDefault = triggerBeforeInput({ code: 'KeyF', key: 'f' }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).toHaveBeenCalledWith('ui:findInBrowserPage', { + browserPageId: browserTabId, + browserWorkspaceId: 'workspace-1' + }) + }) + + it('does not broadcast browser Find without a registered workspace owner', () => { + setupGuestShortcutForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const preventDefault = triggerBeforeInput({ code: 'KeyF', key: 'f' }) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(rendererSendMock).not.toHaveBeenCalled() + }) + it('forwards quick-command menu shortcuts from focused guest pages', () => { setupGuestShortcutForwarding({ browserTabId, diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 3e438c988cd..b9c52b39aa7 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -20,6 +20,7 @@ import { ModifierDoubleTapDetector, toModifierDoubleTapEvent } from '../../shared/modifier-double-tap-detector' +import type { BrowserFindSource } from '../../shared/browser-find-source' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean @@ -264,6 +265,7 @@ export function setupGuestShortcutForwarding(args: { getKeybindings?: () => KeybindingOverrides | undefined // Why: a floating-panel guest owns a distinct workspace; its close/index chords must route to the panel, not the main tab strip. resolveWorktreeId?: (browserTabId: string) => string | null + resolveWorkspaceId?: (browserTabId: string) => string | null }): () => void { const { browserTabId, @@ -272,7 +274,8 @@ export function setupGuestShortcutForwarding(args: { shouldForwardDictationShortcut, isMobileEmulatorEnabled, getKeybindings, - resolveWorktreeId + resolveWorktreeId, + resolveWorkspaceId } = args let ctrlTabSwitching = false const doubleTapDetector = new ModifierDoubleTapDetector() @@ -397,8 +400,15 @@ export function setupGuestShortcutForwarding(args: { // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips. renderer.send('ui:reloadBrowserPage') } else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) { - // Why: guest-native find UI is invisible behind Orca's chrome; forward so the renderer opens its own find-in-page bar. - renderer.send('ui:findInBrowserPage') + const browserWorkspaceId = resolveWorkspaceId?.(browserTabId) + if (browserWorkspaceId) { + const source: BrowserFindSource = { + browserPageId: browserTabId, + browserWorkspaceId + } + // Why: active browser splits share one renderer; preserve the registered guest owner so only its Find bar opens. + renderer.send('ui:findInBrowserPage', source) + } } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). renderer.send('ui:browserHistoryNavigate', 'back') diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 6b51dc9a0dd..63ef02efcb4 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1715,7 +1715,8 @@ export class BrowserManager { shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null }) ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f52d07fd770..81796d17eb3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, @@ -3113,7 +3114,7 @@ export type PreloadApi = { replyTabClose: (reply: { requestId: string; error?: string }) => void onNewTerminalTab: (callback: () => void) => () => void onFocusBrowserAddressBar: (callback: () => void) => () => void - onFindInBrowserPage: (callback: () => void) => () => void + onFindInBrowserPage: (source: BrowserFindSource, callback: () => void) => () => void onReloadBrowserPage: (callback: () => void) => () => void onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void diff --git a/src/preload/browser-find-subscriptions.test.ts b/src/preload/browser-find-subscriptions.test.ts new file mode 100644 index 00000000000..9e49f5cb4ca --- /dev/null +++ b/src/preload/browser-find-subscriptions.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' + +const FIRST_SOURCE = { + browserPageId: 'page-1', + browserWorkspaceId: 'workspace-1' +} +const SAME_WORKSPACE_SOURCE = { + browserPageId: 'page-2', + browserWorkspaceId: FIRST_SOURCE.browserWorkspaceId +} +const SAME_PAGE_SOURCE = { + browserPageId: FIRST_SOURCE.browserPageId, + browserWorkspaceId: 'workspace-2' +} + +describe('browser Find subscriptions', () => { + it('dispatches only to the exact browser page and workspace owner', () => { + const subscriptions = createBrowserFindSubscriptions() + const firstCallback = vi.fn() + const sameWorkspaceCallback = vi.fn() + const samePageCallback = vi.fn() + subscriptions.subscribe(FIRST_SOURCE, firstCallback) + subscriptions.subscribe(SAME_WORKSPACE_SOURCE, sameWorkspaceCallback) + subscriptions.subscribe(SAME_PAGE_SOURCE, samePageCallback) + + subscriptions.dispatch(FIRST_SOURCE) + + expect(firstCallback).toHaveBeenCalledOnce() + expect(sameWorkspaceCallback).not.toHaveBeenCalled() + expect(samePageCallback).not.toHaveBeenCalled() + }) + + it('rejects malformed and partial source identities', () => { + const subscriptions = createBrowserFindSubscriptions() + const callback = vi.fn() + subscriptions.subscribe(FIRST_SOURCE, callback) + + subscriptions.dispatch(undefined) + subscriptions.dispatch({ browserPageId: FIRST_SOURCE.browserPageId }) + subscriptions.dispatch({ + browserPageId: FIRST_SOURCE.browserPageId, + browserWorkspaceId: '' + }) + + expect(callback).not.toHaveBeenCalled() + }) + + it('drops empty ownership entries when subscribers clean up', () => { + const subscriptions = createBrowserFindSubscriptions() + const callback = vi.fn() + const unsubscribe = subscriptions.subscribe(FIRST_SOURCE, callback) + + unsubscribe() + subscriptions.dispatch(FIRST_SOURCE) + + expect(callback).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/browser-find-subscriptions.ts b/src/preload/browser-find-subscriptions.ts new file mode 100644 index 00000000000..2a3a984bd72 --- /dev/null +++ b/src/preload/browser-find-subscriptions.ts @@ -0,0 +1,51 @@ +import { isBrowserFindSource, type BrowserFindSource } from '../shared/browser-find-source' + +type BrowserFindCallback = () => void + +export function createBrowserFindSubscriptions(): { + dispatch: (source: unknown) => void + subscribe: (source: BrowserFindSource, callback: BrowserFindCallback) => () => void +} { + const callbacksByWorkspace = new Map>>() + + return { + dispatch: (source) => { + if (!isBrowserFindSource(source)) { + return + } + const callbacks = callbacksByWorkspace + .get(source.browserWorkspaceId) + ?.get(source.browserPageId) + if (!callbacks) { + return + } + for (const callback of callbacks) { + callback() + } + }, + subscribe: (source, callback) => { + let callbacksByPage = callbacksByWorkspace.get(source.browserWorkspaceId) + if (!callbacksByPage) { + callbacksByPage = new Map() + callbacksByWorkspace.set(source.browserWorkspaceId, callbacksByPage) + } + let callbacks = callbacksByPage.get(source.browserPageId) + if (!callbacks) { + callbacks = new Set() + callbacksByPage.set(source.browserPageId, callbacks) + } + callbacks.add(callback) + + return () => { + callbacks.delete(callback) + if (callbacks.size > 0) { + return + } + callbacksByPage.delete(source.browserPageId) + if (callbacksByPage.size === 0) { + callbacksByWorkspace.delete(source.browserWorkspaceId) + } + } + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fa42ae400fe..7c63fd9756d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -211,6 +211,7 @@ import type { PreloadApi } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -457,6 +458,11 @@ document.addEventListener( ) const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' +const browserFindSubscriptions = createBrowserFindSubscriptions() + +ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { + browserFindSubscriptions.dispatch(source) +}) // Custom APIs for renderer const api = { @@ -3579,11 +3585,7 @@ const api = { ipcRenderer.on('ui:focusBrowserAddressBar', listener) return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) }, - onFindInBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:findInBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:findInBrowserPage', listener) - }, + onFindInBrowserPage: browserFindSubscriptions.subscribe, onReloadBrowserPage: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:reloadBrowserPage', listener) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 92805781559..1a2c0279543 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -753,13 +753,31 @@ function retryBrowserTabLoad( webview.src = retryUrl } +export type BrowserFindShortcutScope = 'focused' | 'inactive' | 'owned-target' + +function browserOverlayOwnsShortcutTarget( + target: EventTarget | null, + browserTabId: string +): boolean { + if (!(target instanceof Element)) { + return false + } + return ( + target.closest('[data-browser-overlay-tab-id]')?.getAttribute('data-browser-overlay-tab-id') === + browserTabId + ) +} + export default function BrowserPane({ browserTab, - isActive + isActive, + findShortcutScope }: { browserTab: BrowserWorkspaceState isActive: boolean + findShortcutScope?: BrowserFindShortcutScope }): React.JSX.Element { + const resolvedFindShortcutScope = findShortcutScope ?? (isActive ? 'focused' : 'inactive') const activeRuntimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, browserTab.worktreeId) ) @@ -852,6 +870,9 @@ export default function BrowserPane({ sessionProfileId={browserTab.sessionProfileId ?? null} sessionPartition={browserTab.sessionPartition ?? null} isActive={isActive && page.id === activeBrowserPage?.id} + findShortcutScope={ + page.id === activeBrowserPage?.id ? resolvedFindShortcutScope : 'inactive' + } isAutomationVisible={automationVisiblePageIds.has(page.id)} isMobileDriven={mobileDrivenPageIds.has(page.id)} inputLocked={activeBrowserDriver.kind === 'mobile'} @@ -2755,6 +2776,7 @@ function BrowserPagePane({ sessionProfileId, sessionPartition, isActive, + findShortcutScope, isAutomationVisible, isMobileDriven, inputLocked, @@ -2767,6 +2789,7 @@ function BrowserPagePane({ sessionProfileId: string | null sessionPartition: string | null isActive: boolean + findShortcutScope: BrowserFindShortcutScope isAutomationVisible: boolean isMobileDriven: boolean inputLocked: boolean @@ -3447,7 +3470,7 @@ function BrowserPagePane({ // Cmd/Ctrl+F — find in page (renderer path: focus on browser chrome) // Why: unlike bare C/S grab shortcuts, Cmd+F should always open find even from the address bar (matches Chrome/Safari). useEffect(() => { - if (!isActive) { + if (findShortcutScope === 'inactive') { return } const shortcutPlatform = getShortcutPlatform() @@ -3455,13 +3478,19 @@ function BrowserPagePane({ if (!keybindingMatchesAction('browser.find', e, shortcutPlatform, keybindings)) { return } + if ( + findShortcutScope === 'owned-target' && + !browserOverlayOwnsShortcutTarget(e.target, workspaceId) + ) { + return + } e.preventDefault() e.stopPropagation() setFindOpen(true) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [isActive, keybindings]) + }, [findShortcutScope, keybindings, workspaceId]) // Cmd/Ctrl+F — find in page (IPC path: focus inside webview guest) // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. @@ -3469,10 +3498,13 @@ function BrowserPagePane({ if (!isActive) { return } - return window.api.ui.onFindInBrowserPage(() => { - setFindOpen(true) - }) - }, [isActive]) + return window.api.ui.onFindInBrowserPage( + { browserPageId: browserTab.id, browserWorkspaceId: workspaceId }, + () => { + setFindOpen(true) + } + ) + }, [browserTab.id, isActive, workspaceId]) // Browser history shortcuts (renderer path: focus on browser chrome) // Why: macOS can't deliver Logitech side-buttons to Electron; Logi Options+ remaps them to history chords, handled here when chrome is focused. diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx index 3b8c8ad0299..4e49c241722 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.test.tsx @@ -6,6 +6,7 @@ type MockAppState = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record groupsByWorktree: Record + activeGroupIdByWorktree: Record focusGroup: (worktreeId: string, groupId: string) => void } @@ -36,10 +37,19 @@ vi.mock('@/lib/pane-manager/browser-mobile-driver-state', () => ({ })) vi.mock('./BrowserPane', () => ({ - default: ({ browserTab, isActive }: { browserTab: BrowserTabState; isActive: boolean }) => ( + default: ({ + browserTab, + isActive, + findShortcutScope + }: { + browserTab: BrowserTabState + isActive: boolean + findShortcutScope?: string + }) => ( ) })) @@ -63,6 +73,88 @@ describe('BrowserPaneOverlayLayer', () => { expect(markup).toContain('data-browser-pane-active="false"') }) + it('marks the active browser pane focused when its own group holds focus', () => { + const markup = renderOverlay({ isWorktreeActive: true }) + + // browser-a is the active tab of group-1, and group-1 is the focused split. + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="focused"' + ) + }) + + it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => { + mocks.state = createState() + mocks.state.groupsByWorktree = { + 'wt-1': [ + ...mocks.state.groupsByWorktree['wt-1'], + { + id: 'group-2', + worktreeId: 'wt-1', + activeTabId: null, + tabOrder: [] + } + ] + } + mocks.state.activeGroupIdByWorktree = { 'wt-1': 'group-2' } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="inactive"' + ) + }) + + it('limits Find to the owning target while focused-group state is unavailable', () => { + mocks.state = createState() + mocks.state.activeGroupIdByWorktree = {} + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + + it('limits Find to the owning target when the focused-group ID is stale', () => { + mocks.state = createState() + mocks.state.activeGroupIdByWorktree = { 'wt-1': 'removed-group' } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + + it('keeps Find ownership with group identities after split order changes', () => { + mocks.state = createState() + const [tabA, tabB] = mocks.state.unifiedTabsByWorktree['wt-1'] + const [groupA] = mocks.state.groupsByWorktree['wt-1'] + mocks.state.unifiedTabsByWorktree = { + 'wt-1': [{ ...tabB, groupId: 'group-2' }, tabA] + } + mocks.state.groupsByWorktree = { + 'wt-1': [ + { + id: 'group-2', + worktreeId: 'wt-1', + activeTabId: tabB.id, + tabOrder: [tabB.id] + }, + { ...groupA, tabOrder: [tabA.id] } + ] + } + + const markup = renderOverlay({ isWorktreeActive: true }) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="focused"' + ) + expect(markup).toContain( + 'data-browser-pane-id="browser-b" data-browser-pane-active="true" data-browser-find-shortcut-scope="inactive"' + ) + }) + it('marks automation-visible inactive browser panes paintable without remounting them', () => { mocks.automationVisiblePageIds.add('page-b') @@ -126,6 +218,8 @@ function createState(): MockAppState { } ] }, + // Default: the browser's own group holds focus, so active === focused. + activeGroupIdByWorktree: { 'wt-1': 'group-1' }, focusGroup: mocks.focusGroup } } diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 00008a0500f..b019f1025e7 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -3,7 +3,7 @@ import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' -import BrowserPane from './BrowserPane' +import BrowserPane, { type BrowserFindShortcutScope } from './BrowserPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility' import { useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' @@ -24,6 +24,7 @@ type BrowserOverlaySlotProps = { // Why: undefined = orphan tab (in browserTabs but not referenced by any group's unified-tab list); the fallback branch keeps these hidden. groupId: string | undefined isActive: boolean + findShortcutScope: BrowserFindShortcutScope // Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale. onFocusOwningGroup: ((groupId: string) => void) | undefined isWorktreeActive: boolean @@ -34,6 +35,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ browserTab, groupId, isActive, + findShortcutScope, onFocusOwningGroup, isWorktreeActive }: BrowserOverlaySlotProps): React.JSX.Element { @@ -97,12 +99,18 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ >
{/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */} - {shouldMountPane ? : null} + {shouldMountPane ? ( + + ) : null}
) }) -// Why: memoize so parent re-renders on props this layer doesn't consume (e.g. focusedGroupId) don't rerun its selector or assignments mapping. +// Why: memoize so parent re-renders on props this layer doesn't consume don't rerun its selector or assignments mapping (focused-split state comes from the store selector below, not props). const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ worktreeId, isWorktreeActive @@ -110,14 +118,23 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ worktreeId: string isWorktreeActive: boolean }): React.JSX.Element { - const { browserTabs, unifiedTabs, groups } = useAppStore( + const { browserTabs, unifiedTabs, groups, focusedGroupId } = useAppStore( useShallow((state) => ({ browserTabs: state.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS, unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, - groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + // Why: the focused split within this worktree; gates the browser Find shortcut so a focused terminal in the same split keeps Cmd/Ctrl+F (#11348). + focusedGroupId: state.activeGroupIdByWorktree[worktreeId] })) ) const focusGroup = useAppStore((state) => state.focusGroup) + const knownFocusedGroupId = useMemo( + () => + focusedGroupId !== undefined && groups.some((group) => group.id === focusedGroupId) + ? focusedGroupId + : undefined, + [focusedGroupId, groups] + ) // Why: stable identity so BrowserOverlaySlot's memo holds; groupId is passed at call time so one callback serves every slot. const focusOwningGroup = useCallback( @@ -154,12 +171,20 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ {browserTabs.map((browserTab) => { const assignment = assignments.get(browserTab.id) const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const findShortcutScope: BrowserFindShortcutScope = !isActive + ? 'inactive' + : knownFocusedGroupId === undefined + ? 'owned-target' + : assignment?.groupId === knownFocusedGroupId + ? 'focused' + : 'inactive' return ( diff --git a/src/shared/browser-find-source.ts b/src/shared/browser-find-source.ts new file mode 100644 index 00000000000..8b3dbe5c7b1 --- /dev/null +++ b/src/shared/browser-find-source.ts @@ -0,0 +1,17 @@ +export type BrowserFindSource = { + browserPageId: string + browserWorkspaceId: string +} + +export function isBrowserFindSource(value: unknown): value is BrowserFindSource { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const source = value as Partial + return ( + typeof source.browserPageId === 'string' && + source.browserPageId.length > 0 && + typeof source.browserWorkspaceId === 'string' && + source.browserWorkspaceId.length > 0 + ) +} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 20f3938da81..edf2bfa22c6 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -286,6 +286,25 @@ describe('keybindings', () => { expect(keybindingMatchesAction('editor.addReviewNote', oldCtrlAltChord, 'win32')).toBe(false) }) + it('maps browser Find to Command on macOS and Control elsewhere', () => { + const commandF = { + key: 'f', + code: 'KeyF', + meta: true, + control: false, + alt: false, + shift: false + } + const controlF = { ...commandF, meta: false, control: true } + + expect(keybindingMatchesAction('browser.find', commandF, 'darwin')).toBe(true) + expect(keybindingMatchesAction('browser.find', controlF, 'darwin')).toBe(false) + expect(keybindingMatchesAction('browser.find', controlF, 'linux')).toBe(true) + expect(keybindingMatchesAction('browser.find', controlF, 'win32')).toBe(true) + expect(keybindingMatchesAction('browser.find', commandF, 'linux')).toBe(false) + expect(keybindingMatchesAction('browser.find', commandF, 'win32')).toBe(false) + }) + it('defines platform-native replace-in-editor shortcuts', () => { expect(getEffectiveKeybindingsForAction('editor.replace', 'darwin')).toEqual(['Mod+Alt+F']) expect(getEffectiveKeybindingsForAction('editor.replace', 'linux')).toEqual(['Mod+H']) diff --git a/tests/e2e/browser-split-find-shortcut.spec.ts b/tests/e2e/browser-split-find-shortcut.spec.ts new file mode 100644 index 00000000000..a97448ec019 --- /dev/null +++ b/tests/e2e/browser-split-find-shortcut.spec.ts @@ -0,0 +1,296 @@ +import { expect, test } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { focusActiveTerminalInput } from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const modifier = process.platform === 'darwin' ? 'Meta' : 'Control' + +type SplitFindFixture = { + browserGroupId: string + browserTabId: string + terminalGroupId: string +} + +type BrowserSplitFixture = { + firstBrowserTabId: string + secondBrowserTabId: string +} + +async function createTerminalBrowserSplit(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('Active worktree unavailable') + } + const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId) + const browserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right') + if (!browserGroupId) { + throw new Error('Browser split unavailable') + } + const browserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: browserGroupId + }) + return { browserGroupId, browserTabId: browserTab.id, terminalGroupId } + }) +} + +async function createBrowserSplit(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('Active worktree unavailable') + } + const terminalGroupId = state.ensureWorktreeRootGroup(worktreeId) + const firstBrowserGroupId = state.createEmptySplitGroup(worktreeId, terminalGroupId, 'right') + if (!firstBrowserGroupId) { + throw new Error('First browser split unavailable') + } + const firstBrowserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: firstBrowserGroupId + }) + const secondBrowserGroupId = state.createEmptySplitGroup( + worktreeId, + firstBrowserGroupId, + 'right' + ) + if (!secondBrowserGroupId) { + throw new Error('Second browser split unavailable') + } + const secondBrowserTab = state.createBrowserTab(worktreeId, 'about:blank', { + activate: true, + focusAddressBar: false, + targetGroupId: secondBrowserGroupId + }) + return { + firstBrowserTabId: firstBrowserTab.id, + secondBrowserTabId: secondBrowserTab.id + } + }) +} + +function browserAddressBar(page: Page, browserTabId: string) { + return page.locator( + `[data-browser-overlay-tab-id="${browserTabId}"] [data-orca-browser-address-bar="true"]` + ) +} + +function browserFindInput(page: Page) { + return page.getByPlaceholder('Find in page...') +} + +function browserFindCloseButton(page: Page) { + return browserFindInput(page).locator('xpath=..').getByTitle('Close') +} + +function browserSplitFindInput(page: Page, browserTabId: string) { + return page + .locator(`[data-browser-overlay-tab-id="${browserTabId}"]`) + .getByPlaceholder('Find in page...') +} + +async function pressFindInBrowserGuest(page: Page, browserTabId: string): Promise { + await expect + .poll(() => + page.evaluate((targetBrowserTabId) => { + const overlay = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = overlay?.querySelector('webview') as Electron.WebviewTag | null + return webview?.getWebContentsId() ?? null + }, browserTabId) + ) + .not.toBeNull() + + await page.evaluate( + async ({ targetBrowserTabId, inputModifier }) => { + const overlay = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = overlay?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + webview.focus() + await webview.sendInputEvent({ + type: 'keyDown', + keyCode: 'F', + modifiers: [inputModifier] + }) + await webview.sendInputEvent({ + type: 'keyUp', + keyCode: 'F', + modifiers: [inputModifier] + }) + }, + { targetBrowserTabId: browserTabId, inputModifier: modifier.toLowerCase() } + ) +} + +function terminalFindInput(page: Page) { + return page.locator('[data-terminal-search-root] input:visible') +} + +async function waitForFocusedGroup(page: Page, groupId: string): Promise { + await expect + .poll(() => + page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + return worktreeId ? state.activeGroupIdByWorktree[worktreeId] : null + }) + ) + .toBe(groupId) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) +} + +async function focusBrowserGroup(page: Page, groupId: string): Promise { + await page.evaluate((targetGroupId) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (state && worktreeId) { + state.focusGroup(worktreeId, targetGroupId) + } + }, groupId) + await waitForFocusedGroup(page, groupId) +} + +test.describe('browser split Find shortcut', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('routes repeated Find shortcuts to the focused terminal or browser split', async ({ + orcaPage + }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + + await orcaPage.evaluate(({ terminalGroupId }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + if (state && worktreeId) { + state.focusGroup(worktreeId, terminalGroupId) + } + }, fixture) + await focusActiveTerminalInput(orcaPage) + await waitForFocusedGroup(orcaPage, fixture.terminalGroupId) + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(terminalFindInput(orcaPage)).toBeFocused() + await expect(browserFindInput(orcaPage)).toBeHidden() + await orcaPage.keyboard.press('Escape') + + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + const browserAddress = browserAddressBar(orcaPage, fixture.browserTabId) + await expect(browserAddress).toBeVisible() + await browserAddress.click() + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + await browserFindCloseButton(orcaPage).click() + await expect(browserFindInput(orcaPage)).toBeHidden() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await browserFindCloseButton(orcaPage).click() + + await orcaPage.evaluate(({ browserTabId }) => { + window.__store?.getState().closeBrowserTab(browserTabId) + }, fixture) + await expect( + orcaPage.locator(`[data-browser-overlay-tab-id="${fixture.browserTabId}"]`) + ).toHaveCount(0) + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(terminalFindInput(orcaPage)).toBeFocused() + await expect(browserFindInput(orcaPage)).toBeHidden() + }) + + test('opens Find only in the browser split whose guest owns the shortcut', async ({ + orcaPage + }) => { + const fixture = await createBrowserSplit(orcaPage) + + await pressFindInBrowserGuest(orcaPage, fixture.firstBrowserTabId) + + await expect(browserSplitFindInput(orcaPage, fixture.firstBrowserTabId)).toBeVisible() + await expect(browserSplitFindInput(orcaPage, fixture.secondBrowserTabId)).toBeHidden() + }) + + test('keeps browser Find available when split focus state is temporarily missing', async ({ + orcaPage + }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) + await expect(addressBar).toBeVisible() + await addressBar.click() + await expect(addressBar).toBeFocused() + + await orcaPage.evaluate(() => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Active worktree unavailable') + } + store.setState((state) => { + const activeGroupIdByWorktree = { ...state.activeGroupIdByWorktree } + delete activeGroupIdByWorktree[worktreeId] + return { activeGroupIdByWorktree } + }) + }) + await expect(addressBar).toBeFocused() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + }) + + test('keeps browser Find available when the focused split ID is stale', async ({ orcaPage }) => { + const fixture = await createTerminalBrowserSplit(orcaPage) + await focusBrowserGroup(orcaPage, fixture.browserGroupId) + const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) + await expect(addressBar).toBeVisible() + await addressBar.click() + await expect(addressBar).toBeFocused() + + await orcaPage.evaluate(() => { + const store = window.__store + const worktreeId = store?.getState().activeWorktreeId + if (!store || !worktreeId) { + throw new Error('Active worktree unavailable') + } + store.setState((state) => ({ + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: 'removed-group' + } + })) + }) + await expect(addressBar).toBeFocused() + + await orcaPage.keyboard.press(`${modifier}+f`) + await expect(browserFindInput(orcaPage)).toBeFocused() + await expect(terminalFindInput(orcaPage)).toBeHidden() + }) +}) From 5fe3aaf2b75303709086a72c78a756f6b8c3caf8 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Thu, 30 Jul 2026 16:23:30 -0500 Subject: [PATCH 007/123] fix(ssh): preserve first config directive value (#11297) * fix(ssh): preserve first config directive value * test(ssh): cover false-first config booleans * fix(ssh): trust fresh OpenSSH config authority * fix(ssh): preserve ordered config identities --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ssh/ssh-auth-resolution.ts | 91 ++++++--- src/main/ssh/ssh-config-parser.test.ts | 45 +++++ src/main/ssh/ssh-config-parser.ts | 18 +- src/main/ssh/ssh-connection-utils.test.ts | 71 +++++++ src/main/ssh/ssh-connection-utils.ts | 38 +++- src/main/ssh/ssh-connection.test.ts | 59 ++++++ src/main/ssh/ssh-connection.ts | 44 ++++- .../ssh/ssh-multi-key-authentication.test.ts | 180 ++++++++++++++++++ .../ssh/ssh-private-key-authentication.ts | 61 ++++++ src/main/ssh/ssh-system-fallback.test.ts | 19 +- .../ssh-system-transport.integration.test.ts | 2 +- src/main/ssh/system-ssh-args.ts | 8 +- .../ssh/system-ssh-forward-process.test.ts | 4 +- 13 files changed, 572 insertions(+), 68 deletions(-) create mode 100644 src/main/ssh/ssh-multi-key-authentication.test.ts create mode 100644 src/main/ssh/ssh-private-key-authentication.ts diff --git a/src/main/ssh/ssh-auth-resolution.ts b/src/main/ssh/ssh-auth-resolution.ts index a222ff2ee2d..025c8904870 100644 --- a/src/main/ssh/ssh-auth-resolution.ts +++ b/src/main/ssh/ssh-auth-resolution.ts @@ -4,6 +4,7 @@ import type { SshTarget } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { createIdentityFilteredAgent } from './ssh-agent-identity-filter' import { resolveSshConfigHomePath } from './ssh-config-path-expansion' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' // Why: ssh2 only tries keys that are explicitly provided. Users with keys in // standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would @@ -62,12 +63,12 @@ function resolveDefaultAgentSocket(): string | undefined { } export function resolveAgentSocket( - target: Pick, + target: Pick, resolved: Pick | null ): string | undefined { // Why: imported config-host targets may contain raw OpenSSH tokens like %d. // ssh -G resolves those tokens, so its value must win when available. - const configuredIdentityAgent = target.configHost + const configuredIdentityAgent = isOpenSshConfigBackedTarget(target) ? (resolved?.identityAgent ?? target.identityAgent) : (target.identityAgent ?? resolved?.identityAgent) if (configuredIdentityAgent != null) { @@ -80,17 +81,30 @@ export function resolveAgentSocket( return resolveDefaultAgentSocket() } -function resolveExplicitPrivateKeyPath( +function resolveExplicitPrivateKeyPaths( target: SshTarget, resolved: SshResolvedConfig | null -): string | undefined { - const resolvedIdentity = resolved?.identityFile?.[0] - return ( - target.identityFile || - (resolvedIdentity && !EXPANDED_DEFAULT_KEY_PATHS.includes(resolvedIdentity) - ? resolvedIdentity - : undefined) +): string[] { + const resolvedIdentities = (resolved?.identityFile ?? []).filter( + (identityFile) => !EXPANDED_DEFAULT_KEY_PATHS.includes(identityFile) ) + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolvedIdentities + } + if (target.identityFile) { + return [target.identityFile] + } + return resolvedIdentities +} + +function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolved.identityFile + } + if (target.identityFile) { + return [target.identityFile] + } + return resolved?.identityFile ?? [] } function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { @@ -102,25 +116,34 @@ function readPrivateKey(keyPath: string): PrivateKeyFile | undefined { } } -function resolveExplicitPrivateKey( - target: SshTarget, - resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - const explicitKey = resolveExplicitPrivateKeyPath(target, resolved) - if (explicitKey) { - return readPrivateKey(explicitKey) +function readPrivateKeys(keyPaths: string[]): PrivateKeyFile[] { + const keys: PrivateKeyFile[] = [] + for (const keyPath of keyPaths) { + const key = readPrivateKey(keyPath) + if (key) { + keys.push(key) + } } - return undefined + return keys } -export function resolvePrivateKey( +function resolveExplicitPrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - if (resolveExplicitPrivateKeyPath(target, resolved)) { - return resolveExplicitPrivateKey(target, resolved) +): PrivateKeyFile[] { + return readPrivateKeys(resolveExplicitPrivateKeyPaths(target, resolved)) +} + +export function resolvePrivateKeys( + target: SshTarget, + resolved: SshResolvedConfig | null +): PrivateKeyFile[] { + const keyPaths = resolvePrivateKeyPaths(target, resolved) + if (keyPaths.length > 0 || resolved || target.identityFile) { + return readPrivateKeys(keyPaths) } - return findDefaultKeyFile() + const defaultKey = findDefaultKeyFile() + return defaultKey ? [defaultKey] : [] } function isUnencryptedPrivateKey(contents: Buffer): boolean { @@ -132,19 +155,27 @@ function isUnencryptedPrivateKey(contents: Buffer): boolean { return keys.some((key) => key && typeof key.isPrivateKey === 'function' && key.isPrivateKey()) } -export function resolveUnencryptedExplicitPrivateKey( +export function resolveUnencryptedExplicitPrivateKeys( target: SshTarget, resolved: SshResolvedConfig | null -): PrivateKeyFile | undefined { - const key = resolveExplicitPrivateKey(target, resolved) - if (!key) { - return undefined +): PrivateKeyFile[] { + return resolveExplicitPrivateKeys(target, resolved).filter((key) => + isUnencryptedPrivateKey(key.contents) + ) +} + +export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | undefined { + for (const key of keys) { + const parsed = utils.parseKey(key.contents) as ParsedKey | ParsedKey[] | Error + if (parsed instanceof Error && /passphrase|encrypted key|bad decrypt/i.test(parsed.message)) { + return key.path + } } - return isUnencryptedPrivateKey(key.contents) ? key : undefined + return undefined } function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] { - if (target.configHost && resolved?.identityFile?.length) { + if (isOpenSshConfigBackedTarget(target) && resolved) { return resolved.identityFile } if (target.identityFile) { diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index a9eda1f0248..e48b77baab9 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -197,6 +197,51 @@ Host disabled expect(hosts[1].gssapiAuthentication).toBe(false) }) + it('keeps the first value for repeated single-valued options like OpenSSH', () => { + const hosts = parseSshConfig(` +Host repeated + HostName first.example.com + HostName second.example.com + User first-user + User second-user + Port 2201 + Port 2202 + IdentityAgent ~/.ssh/first-agent.sock + IdentityAgent ~/.ssh/second-agent.sock + IdentitiesOnly yes + IdentitiesOnly no + ProxyCommand ssh -W %h:%p first-bastion + ProxyCommand ssh -W %h:%p second-bastion + ProxyUseFdpass yes + ProxyUseFdpass no + ProxyJump first-jump + ProxyJump second-jump + +Host repeated-disabled + IdentitiesOnly no + IdentitiesOnly yes + ProxyUseFdpass no + ProxyUseFdpass yes +`) + + expect(hosts[0]).toEqual({ + host: 'repeated', + hostname: 'first.example.com', + user: 'first-user', + port: 2201, + identityAgent: testHomePath('.ssh', 'first-agent.sock'), + identitiesOnly: true, + proxyCommand: 'ssh -W %h:%p first-bastion', + proxyUseFdpass: true, + proxyJump: 'first-jump' + }) + expect(hosts[1]).toEqual({ + host: 'repeated-disabled', + identitiesOnly: false, + proxyUseFdpass: false + }) + }) + it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => { const config = ` Host internal diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 92cec9dac0e..1ac6d8ed4bf 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -74,20 +74,21 @@ export function parseSshConfig(content: string): SshConfigHost[] { const value = parseScalarConfigValue(rawValue) + // Why: OpenSSH uses the first obtained value for single-valued parameters. switch (key) { case 'hostname': for (const host of current) { - host.hostname = value + host.hostname ??= value } break case 'port': for (const host of current) { - host.port = Number.parseInt(value, 10) || 22 + host.port ??= Number.parseInt(value, 10) || 22 } break case 'user': for (const host of current) { - host.user = value + host.user ??= value } break case 'identityfile': @@ -97,17 +98,16 @@ export function parseSshConfig(content: string): SshConfigHost[] { break case 'identityagent': for (const host of current) { - host.identityAgent = resolveSshConfigHomePath(value) + host.identityAgent ??= resolveSshConfigHomePath(value) } break case 'identitiesonly': for (const host of current) { - host.identitiesOnly = value.toLowerCase() === 'yes' + host.identitiesOnly ??= value.toLowerCase() === 'yes' } break case 'gssapiauthentication': for (const host of current) { - // Why: OpenSSH uses the first obtained value for each parameter. host.gssapiAuthentication ??= value.toLowerCase() === 'yes' } break @@ -115,17 +115,17 @@ export function parseSshConfig(content: string): SshConfigHost[] { for (const host of current) { // Why: OpenSSH treats ProxyCommand as a shell snippet and preserves // the rest of the line, including quotes and `#` characters. - host.proxyCommand = rawValue.trim() + host.proxyCommand ??= rawValue.trim() } break case 'proxyusefdpass': for (const host of current) { - host.proxyUseFdpass = value.toLowerCase() === 'yes' + host.proxyUseFdpass ??= value.toLowerCase() === 'yes' } break case 'proxyjump': for (const host of current) { - host.proxyJump = value + host.proxyJump ??= value } break } diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 50f2eb66ff2..a5dd743fd43 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -438,6 +438,44 @@ describe('buildConnectConfig', () => { expect(config.port).toBe(2022) }) + it('uses fresh OpenSSH endpoint authority for imported config targets', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + host: 'stale.example.com', + port: 2022, + username: 'stale-user' + }), + makeResolved({ + hostname: 'current.example.com', + port: 2202, + user: 'current-user' + }) + ) + + expect(config.host).toBe('current.example.com') + expect(config.port).toBe(2202) + expect(config.username).toBe('current-user') + }) + + it('keeps imported endpoint fields as the fallback when ssh -G is unavailable', () => { + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + host: 'fallback.example.com', + port: 2022, + username: 'fallback-user' + }), + null + ) + + expect(config.host).toBe('fallback.example.com') + expect(config.port).toBe(2022) + expect(config.username).toBe('fallback-user') + }) + it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => { const config = buildConnectConfig(makeTarget(), null) expect(config.readyTimeout).toBe(30_000) @@ -581,6 +619,22 @@ describe('buildConnectConfig', () => { } }) + it('uses fresh OpenSSH IdentityFile authority for imported config targets', () => { + mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path))) + const config = buildConnectConfig( + makeTarget({ + source: 'ssh-config', + configHost: 'workbox', + identityFile: '/home/user/.ssh/stale' + }), + makeResolved({ identityFile: ['/home/user/.ssh/current'] }), + { includeAgent: false, includePrivateKey: true } + ) + + expect(config.privateKey).toEqual(Buffer.from('/home/user/.ssh/current')) + expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/current') + }) + it('expands Windows-style target.identityFile before reading private key', () => { mockReadFileSync.mockReturnValue(Buffer.from('key')) const config = buildConnectConfig(makeTarget({ identityFile: '~\\.ssh\\custom' }), null, { @@ -664,6 +718,23 @@ describe('resolveEffectiveProxy', () => { }) }) + it('uses fresh OpenSSH proxy authority for imported config targets', () => { + const target = { + ...makeTarget(), + source: 'ssh-config' as const, + configHost: 'workbox', + proxyCommand: 'ssh -W %h:%p stale-bastion' + } + + expect(resolveEffectiveProxy(target, makeResolved())).toBeUndefined() + expect( + resolveEffectiveProxy(target, makeResolved({ proxyCommand: 'ssh -W %h:%p current-bastion' })) + ).toEqual({ + kind: 'proxy-command', + command: 'ssh -W %h:%p current-bastion' + }) + }) + it('falls back to resolved proxyCommand', () => { expect( resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' })) diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index de9e0b6c63f..bce298039be 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -5,11 +5,14 @@ import type { ConnectConfig } from 'ssh2' import type { SshTarget, SshConnectionState } from '../../shared/ssh-types' import type { SshResolvedConfig } from './ssh-config-parser' import { + findEncryptedPrivateKeyPath, resolveAgentConfigValue, resolveAgentSocket, - resolvePrivateKey, - resolveUnencryptedExplicitPrivateKey + resolvePrivateKeys, + resolveUnencryptedExplicitPrivateKeys } from './ssh-auth-resolution' +import { configurePrivateKeyAuthentication } from './ssh-private-key-authentication' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' export { findDefaultKeyFile, resolveAgentSocket } from './ssh-auth-resolution' @@ -180,7 +183,10 @@ export function buildConnectConfig( ): ConnectConfig { const effectiveHost = resolveEffectiveHost(target, resolved) const effectivePort = resolveEffectivePort(target, resolved) - const effectiveUser = target.username || resolved?.user || '' + const effectiveUser = + isOpenSshConfigBackedTarget(target) && resolved + ? (resolved.user ?? target.username) + : target.username || resolved?.user || '' const config: Record = { host: effectiveHost, @@ -202,18 +208,23 @@ export function buildConnectConfig( config.agentForward = true } - const key = + const keys = (options.includePrivateKey ?? !agent) - ? resolvePrivateKey(target, resolved) - : resolveUnencryptedExplicitPrivateKey(target, resolved) - if (key) { - config.privateKey = key.contents - } + ? resolvePrivateKeys(target, resolved) + : resolveUnencryptedExplicitPrivateKeys(target, resolved) + configurePrivateKeyAuthentication( + config as ConnectConfig, + keys, + findEncryptedPrivateKeyPath(keys) + ) return config as ConnectConfig } function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | null): string { + if (isOpenSshConfigBackedTarget(target) && resolved?.hostname) { + return resolved.hostname + } if (shouldUseResolvedEndpoint(target, resolved)) { return resolved!.hostname } @@ -221,6 +232,9 @@ function resolveEffectiveHost(target: SshTarget, resolved: SshResolvedConfig | n } function resolveEffectivePort(target: SshTarget, resolved: SshResolvedConfig | null): number { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return resolved.port || target.port || 22 + } // Why: imported config aliases store 22 as the schema default even when an // included/wildcard OpenSSH rule later resolves a different effective Port. if (target.configHost && target.port === 22 && resolved?.port) { @@ -249,6 +263,12 @@ export function resolveEffectiveProxy( target: SshTarget, resolved: SshResolvedConfig | null ): EffectiveProxy | undefined { + if (isOpenSshConfigBackedTarget(target) && resolved) { + if (resolved.proxyCommand) { + return { kind: 'proxy-command', command: resolved.proxyCommand } + } + return resolved.proxyJump ? { kind: 'jump-host', jumpHost: resolved.proxyJump } : undefined + } if (target.proxyCommand) { return { kind: 'proxy-command', command: target.proxyCommand } } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 94c3f31838e..b73cc1e1db3 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1165,6 +1165,29 @@ describe('SshConnection', () => { expect(conn.usesSystemSshTransport()).toBe(true) }) + it('uses fresh OpenSSH user for imported GitHub targets', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce( + createResolvedConfig({ hostname: 'github.com', user: 'git' }) + ) + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(1, 'Invalid command: echo ORCA-SYSTEM-SSH-OK') + ) + const conn = new SshConnection( + createTarget({ + source: 'ssh-config', + configHost: 'github.com', + host: 'github.com', + username: 'stale-user' + }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + }) + it('accepts GitHub restricted-shell SSH probes with resolved host and target username', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce( createResolvedConfig({ hostname: 'github.com', user: undefined }) @@ -1530,6 +1553,26 @@ describe('SshConnection', () => { ) }) + it('ignores stale imported GSSAPI when fresh OpenSSH config disables it', async () => { + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: false }) + ) + const conn = new SshConnection( + createTarget({ + source: 'ssh-config', + configHost: 'krb-host', + gssapiAuthentication: true + }), + createCallbacks() + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() + }) + it('falls back to system SSH after an ssh2 auth failure when resolved config enables GSSAPI', async () => { connectBehavior = 'error' connectErrorMessage = 'All configured authentication methods failed' @@ -2086,6 +2129,22 @@ describe('shouldUseSystemSshTransport', () => { it('allows an environment override for e2e coverage', () => { vi.stubEnv('ORCA_SSH_FORCE_SYSTEM_TRANSPORT', '1') expect(shouldUseSystemSshTransport(createTarget(), null)).toBe(true) + vi.unstubAllEnvs() + }) + + it('ignores stale imported proxy fields when fresh OpenSSH config has no proxy', () => { + expect( + shouldUseSystemSshTransport( + createTarget({ + source: 'ssh-config', + configHost: 'workbox', + proxyCommand: 'ssh -W %h:%p stale-bastion' + }), + { + proxyUseFdpass: false + } + ) + ).toBe(false) }) }) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 224f888eadb..c1b73bbbb2d 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -19,6 +19,7 @@ import { } from './ssh-system-fallback' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' import { removeControlSocketPath } from './ssh-control-socket' +import { isOpenSshConfigBackedTarget } from './system-ssh-args' import { INITIAL_RETRY_ATTEMPTS, INITIAL_RETRY_DELAY_MS, @@ -39,6 +40,7 @@ import { type SshExecOptions, type SshConnectionCallbacks } from './ssh-connection-utils' +import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' import type { RemoteHostPlatform } from './ssh-remote-platform' import { resolveSftpTransferPathIfMapped, @@ -82,7 +84,11 @@ function isGitHubRestrictedShellProbeSuccess( return false } - const effectiveUser = (target.username?.trim() || resolvedConfig?.user?.trim())?.toLowerCase() + const effectiveUser = ( + isOpenSshConfigBackedTarget(target) && resolvedConfig + ? resolvedConfig.user?.trim() || target.username?.trim() + : target.username?.trim() || resolvedConfig?.user?.trim() + )?.toLowerCase() if (effectiveUser !== 'git') { return false } @@ -631,7 +637,11 @@ export class SshConnection { return } // Why: ssh2 lacks gssapi-with-mic; GSSAPIAuthentication hosts try Kerberos SSO via system OpenSSH first, then fall through to key/credential auth. - if (this.target.gssapiAuthentication === true) { + if ( + isOpenSshConfigBackedTarget(this.target) && resolved + ? resolved.gssapiAuthentication === true + : this.target.gssapiAuthentication === true + ) { try { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) return @@ -719,14 +729,19 @@ export class SshConnection { throw keyErr } authError = keyErr + const passphraseKeyPath = getPassphrasePrivateKeyPath(keyConfig) // Why: with GSSAPI enabled, let the reactive system-ssh probe try a Kerberos ticket before prompting for the passphrase; the prompt still runs if it fails. if ( - isPassphraseError(authError) && + (isPassphraseError(authError) || passphraseKeyPath) && !this.cachedPassphrase && !isGssapiSystemSshFallbackCandidate(authError, this.target, resolved) ) { passphrasePromptHandled = true - const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' + const detail = + passphraseKeyPath || + this.target.identityFile || + resolved?.identityFile?.[0] || + '(unknown)' const val = await this.callbacks.onCredentialRequest?.( this.target.id, 'passphrase', @@ -770,8 +785,17 @@ export class SshConnection { } // Why: prompt for passphrase on encrypted-key error, then retry with a fresh proxy socket (ssh2 may have destroyed the original). - if (isPassphraseError(authError) && !this.cachedPassphrase && !passphrasePromptHandled) { - const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' + const passphraseKeyPath = getPassphrasePrivateKeyPath(credentialRetryConfig) + if ( + (isPassphraseError(authError) || passphraseKeyPath) && + !this.cachedPassphrase && + !passphrasePromptHandled + ) { + const detail = + passphraseKeyPath || + this.target.identityFile || + resolved?.identityFile?.[0] || + '(unknown)' const val = await this.callbacks.onCredentialRequest(this.target.id, 'passphrase', detail) if (val) { this.cachedPassphrase = val @@ -1362,6 +1386,14 @@ export function shouldUseSystemSshTransport( target: SshTarget, resolved: Pick | null ): boolean { + if (isOpenSshConfigBackedTarget(target) && resolved) { + return ( + process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || + resolved.proxyUseFdpass === true || + resolved.proxyCommand != null || + resolved.proxyJump != null + ) + } return ( process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' || target.proxyCommand != null || diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts new file mode 100644 index 00000000000..693e754b661 --- /dev/null +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + utils, + type AnyAuthMethod, + type AuthenticationType, + type AuthHandlerMiddleware, + type ConnectConfig, + type ParsedKey +} from 'ssh2' + +vi.mock('os', () => ({ + homedir: () => '/home/testuser', + tmpdir: () => '/tmp' +})) + +const mockExistsSync = vi.fn().mockReturnValue(false) +const mockReadFileSync = vi.fn() + +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args) +})) + +import { buildConnectConfig } from './ssh-connection-utils' +import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication' +import { buildSshArgs } from './system-ssh-args' +import type { SshTarget } from '../../shared/ssh-types' +import type { SshResolvedConfig } from './ssh-config-parser' + +function makeTarget(overrides: Partial = {}): SshTarget { + return { + id: 'target-1', + label: 'workbox', + source: 'ssh-config', + configHost: 'workbox', + host: 'stale.example.com', + port: 22, + username: 'stale-user', + identityFile: '/keys/stale-imported', + ...overrides + } +} + +function makeResolved(overrides: Partial = {}): SshResolvedConfig { + return { + hostname: 'current.example.com', + port: 2222, + user: 'current-user', + identityFile: ['/keys/unauthorized-first', '/keys/authorized-second'], + identitiesOnly: true, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + ...overrides + } +} + +function nextAuth( + config: ConnectConfig, + firstAttempt: boolean +): AuthenticationType | AnyAuthMethod | false { + let result: AuthenticationType | AnyAuthMethod | false | undefined + const handler = config.authHandler as AuthHandlerMiddleware + handler( + (firstAttempt ? null : ['publickey']) as unknown as AuthenticationType[], + false, + (attempt) => { + result = attempt + } + ) + return result ?? false +} + +describe('ordered SSH private-key authentication', () => { + beforeEach(() => { + vi.stubEnv('SSH_AUTH_SOCK', '') + mockExistsSync.mockReset() + mockExistsSync.mockReturnValue(false) + mockReadFileSync.mockReset() + mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path))) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('offers every fresh ssh -G IdentityFile in order and ignores the imported snapshot', () => { + const config = buildConnectConfig(makeTarget(), makeResolved(), { + includeAgent: false, + includePrivateKey: true + }) + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/authorized-second') + }) + expect(nextAuth(config, false)).toBe(false) + expect(mockReadFileSync).not.toHaveBeenCalledWith('/keys/stale-imported') + }) + + it('keeps explicit manual keys and unresolved imported keys as singular overrides', () => { + const manual = buildConnectConfig( + makeTarget({ + source: 'manual', + configHost: 'manual.example.com', + host: 'manual.example.com', + identityFile: '/keys/manual' + }), + makeResolved(), + { includeAgent: false, includePrivateKey: true } + ) + const unresolvedImport = buildConnectConfig(makeTarget(), null, { + includeAgent: false, + includePrivateKey: true + }) + + expect(manual.privateKey).toEqual(Buffer.from('/keys/manual')) + expect(manual.authHandler).toBeUndefined() + expect(unresolvedImport.privateKey).toEqual(Buffer.from('/keys/stale-imported')) + expect(unresolvedImport.authHandler).toBeUndefined() + }) + + it('resets ordered authentication for credential retries without extra key reads', () => { + const config = buildConnectConfig(makeTarget(), makeResolved(), { + includeAgent: false, + includePrivateKey: true + }) + const readsAfterResolution = mockReadFileSync.mock.calls.length + + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + config.password = 'retry-password' + expect(nextAuth(config, true)).toMatchObject({ type: 'none' }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'password', + password: 'retry-password' + }) + expect(nextAuth(config, false)).toMatchObject({ + type: 'publickey', + key: Buffer.from('/keys/unauthorized-first') + }) + expect(mockReadFileSync).toHaveBeenCalledTimes(readsAfterResolution) + }) + + it('keeps encrypted-key prompts tied to the matching fresh identity path', () => { + vi.spyOn(utils, 'parseKey').mockImplementation((key) => { + if ( + Buffer.from(key as Buffer) + .toString() + .includes('encrypted-second') + ) { + return new Error('Encrypted private OpenSSH key detected, but no passphrase given') + } + return { isPrivateKey: () => true } as ParsedKey + }) + const config = buildConnectConfig( + makeTarget(), + makeResolved({ identityFile: ['/keys/first', '/keys/encrypted-second'] }), + { includeAgent: false, includePrivateKey: true } + ) + + expect(getPassphrasePrivateKeyPath(config)).toBe('/keys/encrypted-second') + }) + + it('leaves config-host key authority to system OpenSSH', () => { + const args = buildSshArgs(makeTarget(), { resolvedConfig: makeResolved() }) + + expect(args.at(-1)).toBe('workbox') + expect(args).not.toContain('-i') + expect(args).not.toContain('/keys/stale-imported') + expect(args).not.toContain('/keys/unauthorized-first') + expect(args).not.toContain('/keys/authorized-second') + }) +}) diff --git a/src/main/ssh/ssh-private-key-authentication.ts b/src/main/ssh/ssh-private-key-authentication.ts new file mode 100644 index 00000000000..a01a58bf9b6 --- /dev/null +++ b/src/main/ssh/ssh-private-key-authentication.ts @@ -0,0 +1,61 @@ +import type { AnyAuthMethod, AuthenticationType, ConnectConfig, NextAuthHandler } from 'ssh2' +import type { PrivateKeyFile } from './ssh-auth-resolution' + +const passphraseKeyPaths = new WeakMap() + +function buildAuthQueue( + config: ConnectConfig, + keys: PrivateKeyFile[] +): (AuthenticationType | AnyAuthMethod)[] { + const username = config.username ?? '' + const queue: (AuthenticationType | AnyAuthMethod)[] = [{ type: 'none', username }] + if (config.password != null) { + queue.push({ type: 'password', username, password: config.password }) + } + for (const key of keys) { + queue.push({ + type: 'publickey', + username, + key: key.contents, + passphrase: config.passphrase + }) + } + if (config.agent) { + queue.push({ type: 'agent', username, agent: config.agent }) + } + if (config.tryKeyboard) { + queue.push('keyboard-interactive') + } + return queue +} + +export function configurePrivateKeyAuthentication( + config: ConnectConfig, + keys: PrivateKeyFile[], + passphraseKeyPath?: string +): void { + const firstKey = keys[0] + if (!firstKey) { + return + } + config.privateKey = firstKey.contents + if (passphraseKeyPath) { + passphraseKeyPaths.set(config, passphraseKeyPath) + } + if (keys.length === 1) { + return + } + + let queue: (AuthenticationType | AnyAuthMethod)[] = [] + config.authHandler = (authsLeft, _partialSuccess, next) => { + if (authsLeft == null) { + queue = buildAuthQueue(config, keys) + } + const attempt = queue.shift() + next((attempt ?? false) as Parameters[0]) + } +} + +export function getPassphrasePrivateKeyPath(config: ConnectConfig): string | undefined { + return passphraseKeyPaths.get(config) +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 71f94204d58..36312762d08 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -276,7 +276,8 @@ describe('spawnSystemSsh', () => { }) ) - expect(args).toContain('deploy@fdpass-host') + expect(args).toContain('fdpass-host') + expect(args).not.toContain('deploy@fdpass-host') expect(args).not.toContain('resolved.example.com') expect(args).not.toContain('-p') expect(args).not.toContain('-i') @@ -335,7 +336,7 @@ describe('spawnSystemSsh', () => { const standaloneControlIdx = args.indexOf('-S') expect(standaloneControlIdx).toBeGreaterThan(-1) expect(args[standaloneControlIdx + 1]).toBe('none') - expect(args.at(-2)).toBe('deploy@krb-host; touch /tmp/not-run') + expect(args.at(-2)).toBe('krb-host; touch /tmp/not-run') expect(args.at(-1)).toBe('echo ready') }) @@ -345,7 +346,7 @@ describe('spawnSystemSsh', () => { ) expect(args).not.toContain('GSSAPIAuthentication=yes') - expect(args).toContain('deploy@krb-host') + expect(args).toContain('krb-host') }) it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { @@ -359,7 +360,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('injects Orca ControlMaster flags when ssh config only sets ControlPersist', () => { @@ -402,7 +403,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('does not inject Orca ControlMaster flags for unresolved legacy config aliases', () => { @@ -410,7 +411,7 @@ describe('spawnSystemSsh', () => { expectNoOrcaControlMasterArgs(args) expect(args).not.toContain('-S') - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('can inject Orca ControlMaster flags for ssh-config targets with resolved config', () => { @@ -448,7 +449,7 @@ describe('spawnSystemSsh', () => { // split it before /bin/sh receives it. const args = spawnMock.mock.calls[0][1] as string[] expect(args).toContain('--') - expect(args).toContain('deploy@fdpass-host') + expect(args).toContain('fdpass-host') const wrapped = args.at(-1)! expect(wrapped).not.toContain('\n') expect(wrapped).toContain('printf %b "$@"') @@ -482,7 +483,7 @@ describe('spawnSystemSsh', () => { expect(standaloneControlIdx).toBe(-1) expectNoOrcaControlMasterArgs(args) expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') - expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') + expect(args[terminatorIdx + 1]).toBe('fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, expect.any(Array), @@ -497,7 +498,7 @@ describe('spawnSystemSsh', () => { expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, - expect.arrayContaining(['--', 'deploy@fdpass-host', 'echo hello']), + expect.arrayContaining(['--', 'fdpass-host', 'echo hello']), expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] }) ) }) diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index e82eea80409..be5ed622927 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -231,7 +231,7 @@ describe('system SSH transport integration', () => { async () => { delete process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT const conn = new SshConnection( - { ...makeTarget(), gssapiAuthentication: true }, + { ...makeTarget(), source: 'manual', gssapiAuthentication: true }, { onStateChange: vi.fn() } ) await conn.connect() diff --git a/src/main/ssh/system-ssh-args.ts b/src/main/ssh/system-ssh-args.ts index 62231666c08..2bdb91e20dc 100644 --- a/src/main/ssh/system-ssh-args.ts +++ b/src/main/ssh/system-ssh-args.ts @@ -77,7 +77,9 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti } const host = target.configHost || target.host - const userHost = target.username ? `${target.username}@${host}` : host + // Why: OpenSSH owns User for config-backed aliases; imported fallback values + // must not override a fresh wildcard, Include, or Match result. + const userHost = useConfigHost ? host : target.username ? `${target.username}@${host}` : host args.push('--', userHost) return args @@ -166,7 +168,9 @@ function shouldUseOpenSshConfigHost(target: SshTarget): boolean { return isOpenSshConfigBackedTarget(target) } -export function isOpenSshConfigBackedTarget(target: SshTarget): boolean { +export function isOpenSshConfigBackedTarget( + target: Pick +): boolean { if (target.source === 'ssh-config') { return true } diff --git a/src/main/ssh/system-ssh-forward-process.test.ts b/src/main/ssh/system-ssh-forward-process.test.ts index 4178d0e1796..bc63cf8dc1f 100644 --- a/src/main/ssh/system-ssh-forward-process.test.ts +++ b/src/main/ssh/system-ssh-forward-process.test.ts @@ -137,7 +137,7 @@ describe('system SSH forward process', () => { expect(standaloneControlIdx).toBe(-1) expectNoOrcaControlMasterArgs(args) expect(args).toContain('127.0.0.1:5173:127.0.0.1:3000') - expect(args[terminatorIdx + 1]).toBe('deploy@fdpass-host') + expect(args[terminatorIdx + 1]).toBe('fdpass-host') expect(spawnMock).toHaveBeenCalledWith( SYSTEM_SSH_PATH, expect.any(Array), @@ -192,7 +192,7 @@ describe('system SSH forward process', () => { const args = spawnMock.mock.calls[0][1] as string[] expect(args.indexOf('-S')).toBe(-1) expectNoOrcaControlMasterArgs(args) - expect(args).toContain('deploy@workbox') + expect(args).toContain('workbox') }) it('preserves manual target port and identity options in the forwarded ssh command', () => { From d4cfee76bedc6d0ed1f1e2df2e0eec4b388ae89f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:02:08 -0700 Subject: [PATCH 008/123] Add audit-only daemon incarnation evidence (#11606) * feat(daemon): add audit incarnation evidence * fix(daemon): isolate audit evidence observers --- .../managed-hook-owner-identity.ts | 8 +- src/main/daemon/daemon-audit-classifier.ts | 219 +++++++++++ .../daemon-audit-eligibility-event.test.ts | 51 +++ .../daemon/daemon-audit-eligibility-event.ts | 36 ++ src/main/daemon/daemon-entry.ts | 6 +- src/main/daemon/daemon-health.test.ts | 40 +- src/main/daemon/daemon-health.ts | 27 +- .../daemon-incarnation-evidence-types.ts | 75 ++++ .../daemon-incarnation-evidence.test.ts | 345 ++++++++++++++++++ .../daemon/daemon-incarnation-evidence.ts | 267 ++++++++++++++ src/main/daemon/daemon-init.test.ts | 16 +- src/main/daemon/daemon-init.ts | 23 +- .../daemon/daemon-process-inspection.test.ts | 37 ++ src/main/daemon/daemon-process-inspection.ts | 122 +++++++ src/main/daemon/daemon-pty-adapter.test.ts | 103 ++++++ src/main/daemon/daemon-pty-adapter.ts | 253 +++++++++++-- src/main/daemon/daemon-pty-router.test.ts | 29 ++ src/main/daemon/daemon-ready-identity.test.ts | 35 ++ src/main/daemon/daemon-ready-identity.ts | 62 ++++ src/main/daemon/daemon-spawner.ts | 2 + .../degraded-daemon-pty-provider.test.ts | 15 + src/main/daemon/production-launcher.test.ts | 31 +- src/main/daemon/production-launcher.ts | 17 +- src/main/runtime/worktree-teardown.test.ts | 1 + src/shared/daemon-audit-eligibility.ts | 81 ++++ src/shared/telemetry-events.ts | 31 ++ 26 files changed, 1859 insertions(+), 73 deletions(-) create mode 100644 src/main/daemon/daemon-audit-classifier.ts create mode 100644 src/main/daemon/daemon-audit-eligibility-event.test.ts create mode 100644 src/main/daemon/daemon-audit-eligibility-event.ts create mode 100644 src/main/daemon/daemon-incarnation-evidence-types.ts create mode 100644 src/main/daemon/daemon-incarnation-evidence.test.ts create mode 100644 src/main/daemon/daemon-incarnation-evidence.ts create mode 100644 src/main/daemon/daemon-process-inspection.test.ts create mode 100644 src/main/daemon/daemon-process-inspection.ts create mode 100644 src/main/daemon/daemon-ready-identity.test.ts create mode 100644 src/main/daemon/daemon-ready-identity.ts create mode 100644 src/shared/daemon-audit-eligibility.ts diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index e9801c3c489..85941007e12 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -15,14 +15,14 @@ function hasCode(error: unknown, code: string): boolean { return error instanceof Error && 'code' in error && error.code === code } -function parseLinuxStartTicks(statLine: string): string | null { - const commandEnd = statLine.lastIndexOf(') ') +export function parseLinuxStartTicks(statLine: string): string | null { + const commandEnd = statLine.lastIndexOf(')') if (commandEnd < 0) { return null } // Field 22 is index 19 after removing pid and the parenthesized command. const startTicks = statLine - .slice(commandEnd + 2) + .slice(commandEnd + 1) .trim() .split(/\s+/)[19] return startTicks ?? null @@ -115,7 +115,7 @@ async function readHostIdentity(): Promise { return runtimeHostIdentity } -async function readBootIdentity(): Promise { +export async function readBootIdentity(): Promise { if (process.platform === 'linux') { try { const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim() diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts new file mode 100644 index 00000000000..b95408f1415 --- /dev/null +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -0,0 +1,219 @@ +import { lstat } from 'node:fs/promises' +import { + probeDaemonProcessIdentity, + type DaemonEvidenceSource, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type ExactDaemonIncarnation +} from './daemon-incarnation-evidence' +import type { + DaemonAuditFailureTrigger, + DaemonAuditGoneReason +} from '../../shared/daemon-audit-eligibility' + +export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility' + +export type DaemonAuditContext = { + protocolGeneration: number + provider: 'local-daemon' + endpoint: string + tokenPath: string + endpointKind: 'unix-socket' | 'windows-named-pipe' + profileScope: string +} + +export type DaemonAuditObservation = + | { + state: 'present' + reason: 'authenticated_inventory' + trigger: 'inventory_answered' + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation | null + reachability: 'authenticated' + inventoryAuthority: 'authoritative' + processLiveness: 'unknown' + processReason: null + endpointState: DaemonEndpointState + observedAtMs: number + } + | { + state: 'gone' + reason: DaemonAuditGoneReason + trigger: DaemonAuditFailureTrigger + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation + reachability: 'authenticated' | 'disconnected' | 'unknown' + inventoryAuthority: 'unavailable' + processLiveness: 'gone' + processReason: DaemonAuditGoneReason + endpointState: DaemonEndpointState + observedAtMs: number + } + | { + state: 'unknown' + reason: DaemonAuditFailureTrigger + trigger: DaemonAuditFailureTrigger + evidenceSources: DaemonEvidenceSources + context: DaemonAuditContext + exactIncarnation: ExactDaemonIncarnation | null + reachability: 'authenticated' | 'disconnected' | 'unknown' + inventoryAuthority: 'unavailable' + processLiveness: 'present' | 'unknown' + processReason: + | Extract['reason'] + | null + endpointState: DaemonEndpointState + observedAtMs: number + } + +export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown' + +export type DaemonAuditClassifierDependencies = { + probeProcessIdentity?: typeof probeDaemonProcessIdentity + inspectEndpointState?: (context: DaemonAuditContext) => Promise +} + +export type DaemonAuditClassificationOptions = { + additionalEvidenceSources?: readonly DaemonEvidenceSource[] + endpointGoneProof?: 'windows_named_pipe_missing' + dependencies?: DaemonAuditClassifierDependencies +} + +export function recordAuthenticatedInventory( + context: DaemonAuditContext, + exactIncarnation: ExactDaemonIncarnation | null +): DaemonAuditObservation { + return { + state: 'present', + reason: 'authenticated_inventory', + trigger: 'inventory_answered', + evidenceSources: ['authenticated_inventory'], + context, + exactIncarnation, + reachability: 'authenticated', + inventoryAuthority: 'authoritative', + processLiveness: 'unknown', + processReason: null, + endpointState: context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'socket', + observedAtMs: Date.now() + } +} + +export async function classifyDaemonAuditFailure( + context: DaemonAuditContext, + trigger: DaemonAuditFailureTrigger, + exactIncarnation: ExactDaemonIncarnation | null, + options: DaemonAuditClassificationOptions = {} +): Promise { + const probeProcessIdentity = + options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity + const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState + const [processEvidence, endpointState] = await Promise.all([ + probeProcessIdentity(exactIncarnation, { + socketPath: context.endpoint, + tokenPath: context.tokenPath + }), + inspectEndpoint(context) + ]) + const reachability = reachabilityForTrigger(trigger) + const evidenceSources = combineEvidenceSources( + processEvidence.evidenceSources, + context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [], + options.additionalEvidenceSources ?? [] + ) + if ( + options.endpointGoneProof && + context.endpointKind === 'windows-named-pipe' && + exactIncarnation + ) { + return { + state: 'gone', + reason: options.endpointGoneProof, + trigger, + evidenceSources, + context, + exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: 'gone', + processReason: options.endpointGoneProof, + endpointState: 'missing', + observedAtMs: Date.now() + } + } + if (processEvidence.state === 'gone') { + return { + state: 'gone', + reason: processEvidence.reason, + trigger, + evidenceSources, + context, + exactIncarnation: processEvidence.exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: 'gone', + processReason: processEvidence.reason, + endpointState, + observedAtMs: Date.now() + } + } + return { + state: 'unknown', + reason: trigger, + trigger, + evidenceSources, + context, + exactIncarnation, + reachability, + inventoryAuthority: 'unavailable', + processLiveness: processEvidence.state, + processReason: processEvidence.reason, + endpointState, + observedAtMs: Date.now() + } +} + +async function inspectDaemonEndpointState( + context: DaemonAuditContext +): Promise { + if (context.endpointKind === 'windows-named-pipe') { + return 'named-pipe' + } + try { + const stats = await lstat(context.endpoint) + return stats.isSocket() ? 'socket' : 'non-socket' + } catch (error) { + return hasErrorCode(error, 'ENOENT') ? 'missing' : 'unknown' + } +} + +function reachabilityForTrigger( + trigger: DaemonAuditFailureTrigger +): 'authenticated' | 'disconnected' | 'unknown' { + if (trigger === 'endpoint_identity_changed') { + return 'authenticated' + } + if ( + trigger === 'transport_closed' || + trigger === 'token_missing_after_authenticated_disconnect' + ) { + return 'disconnected' + } + return 'unknown' +} + +function combineEvidenceSources( + first: DaemonEvidenceSources, + ...rest: readonly (readonly DaemonEvidenceSource[])[] +): DaemonEvidenceSources { + return [...new Set([first[0], ...first.slice(1), ...rest.flat()])] as [ + DaemonEvidenceSource, + ...DaemonEvidenceSource[] + ] +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/main/daemon/daemon-audit-eligibility-event.test.ts b/src/main/daemon/daemon-audit-eligibility-event.test.ts new file mode 100644 index 00000000000..b17b5ba3892 --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { validate } from '../telemetry/validator' +import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier' + +const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() })) +vi.mock('../telemetry/client', () => ({ track: trackMock })) + +import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event' + +const context: DaemonAuditContext = { + protocolGeneration: 23, + provider: 'local-daemon', + endpoint: '/profile/daemon.sock', + tokenPath: '/profile/daemon.token', + endpointKind: 'unix-socket', + profileScope: '/profile' +} + +beforeEach(() => { + trackMock.mockReset() +}) + +describe('daemon audit eligibility telemetry', () => { + it('uses a dedicated validator-accepted event family', () => { + trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null)) + + expect(trackMock).toHaveBeenCalledOnce() + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_audit_eligibility') + expect(name).not.toBe('daemon_lifecycle') + expect(props).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + evidence_sources: ['authenticated_inventory'], + protocol_generation: 23, + exact_incarnation: 'unavailable', + process_reason: null + }) + expect(validate('daemon_audit_eligibility', props).ok).toBe(true) + }) + + it('cannot affect callers when telemetry throws', () => { + trackMock.mockImplementation(() => { + throw new Error('transport failed') + }) + + expect(() => + trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null)) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-audit-eligibility-event.ts b/src/main/daemon/daemon-audit-eligibility-event.ts new file mode 100644 index 00000000000..0511f7a384a --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.ts @@ -0,0 +1,36 @@ +import { track } from '../telemetry/client' +import type { DaemonAuditObservation } from './daemon-audit-classifier' + +export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void { + try { + track('daemon_audit_eligibility', { + state: observation.state, + reason: observation.reason, + trigger: observation.trigger, + evidence_sources: [...observation.evidenceSources], + protocol_generation: observation.context.protocolGeneration, + provider: observation.context.provider, + endpoint_kind: observation.context.endpointKind, + profile_scope: observation.context.profileScope ? 'configured' : 'unspecified', + exact_incarnation: exactIncarnationKind(observation), + reachability: observation.reachability, + inventory_authority: observation.inventoryAuthority, + process_liveness: observation.processLiveness, + process_reason: observation.processReason, + endpoint_state: observation.endpointState + }) + } catch { + // Audit telemetry cannot affect daemon availability. + } +} + +function exactIncarnationKind( + observation: DaemonAuditObservation +): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' { + if (!observation.exactIncarnation) { + return 'unavailable' + } + return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId + ? 'endpoint-identity-linux-ticks' + : 'endpoint-identity' +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index ba98af21c5b..215786fb472 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -19,6 +19,7 @@ import { } from '../providers/macos-tcc-login-shell' import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' export type ParsedDaemonArgs = { socketPath: string @@ -255,9 +256,8 @@ async function main(): Promise { // Signal readiness to parent via IPC (if available) if (process.send) { - // Why: Windows has no cheap OS query for a child's start time, so the - // daemon self-reports it here for the pid file's pid-recycling guard. - process.send({ type: 'ready', startedAtMs }) + const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) + process.send({ type: 'ready', ...readyIdentity }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 152ed019029..ccfb7d1f0e9 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -198,7 +198,10 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: 1_700_000_000_000, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) @@ -213,7 +216,25 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: 1_700_000_000_000, entryPath: '/repo/out/main/daemon-entry.js', - appVersion: '1.2.3' + appVersion: '1.2.3', + launchNonce: null, + linuxStartTicks: null, + bootId: null + }) + }) + + it('preserves exact-incarnation launch and Linux process identity', () => { + const serialized = serializeDaemonPidFile({ + pid: 12345, + startedAtMs: 1_700_000_000_000, + launchNonce: 'launch-a', + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + expect(parseDaemonPidFile(serialized)).toMatchObject({ + launchNonce: 'launch-a', + linuxStartTicks: '4242', + bootId: 'boot-a' }) }) @@ -224,7 +245,10 @@ describe('parseDaemonPidFile', () => { pid: 9999, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) @@ -236,13 +260,19 @@ describe('parseDaemonPidFile', () => { pid: 12345, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) expect(parseDaemonPidFile(' 12345\n')).toEqual({ pid: 12345, startedAtMs: null, entryPath: null, - appVersion: null + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null }) }) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 2989f9b2268..71bc7f3279b 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -40,11 +40,14 @@ const WIN32_START_TIME_TOLERANCE_MS = 10_000 // also covers a live-but-wedged daemon that simply missed the RPC budget. export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' -type ParsedDaemonPid = { +export type ParsedDaemonPid = { pid: number startedAtMs: number | null entryPath: string | null appVersion: string | null + launchNonce: string | null + linuxStartTicks: string | null + bootId: string | null } function canConnectSocket(socketPath: string): Promise { @@ -301,7 +304,7 @@ export function getMacDaemonSystemResolverHealth( }) } -function commandLineMatchesDaemon( +export function commandLineMatchesDaemon( commandLine: string, socketPath: string, tokenPath: string @@ -321,6 +324,9 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { startedAtMs?: unknown entryPath?: unknown appVersion?: unknown + launchNonce?: unknown + linuxStartTicks?: unknown + bootId?: unknown } if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) { return { @@ -330,7 +336,10 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { ? parsed.startedAtMs : null, entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null, - appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null + appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null, + launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null, + linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null, + bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null } } } catch { @@ -338,7 +347,17 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { } const pid = Number(trimmed) - return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null, appVersion: null } : null + return Number.isFinite(pid) + ? { + pid, + startedAtMs: null, + entryPath: null, + appVersion: null, + launchNonce: null, + linuxStartTicks: null, + bootId: null + } + : null } function getLinuxProcessStartedAtMs(pid: number): number | null { diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts new file mode 100644 index 00000000000..537a03fc191 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -0,0 +1,75 @@ +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' + +export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000 + +// Oracle contract validated 2026-07-29; omitted proofs remain unknown. +export const DAEMON_GONE_PROOFS = { + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], + darwin: ['pid_missing'], + win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing'] +} as const satisfies Readonly< + Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]> +> + +export type { + DaemonAuditGoneReason, + DaemonEvidenceSource, + DaemonProcessGoneReason, + DaemonProcessPresentReason, + DaemonProcessUnknownReason +} from '../../shared/daemon-audit-eligibility' + +export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]] + +export type ExactDaemonIncarnation = { + identity: DaemonEndpointIdentity + linuxStartTicks?: string + bootId?: string +} + +export type DaemonProcessEvidence = + | { + state: 'present' + reason: DaemonProcessPresentReason + evidenceSources: DaemonEvidenceSources + } + | { + state: 'gone' + reason: DaemonProcessGoneReason + evidenceSources: DaemonEvidenceSources + exactIncarnation: ExactDaemonIncarnation + } + | { + state: 'unknown' + reason: DaemonProcessUnknownReason + evidenceSources: DaemonEvidenceSources + } + +export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable' + +export type LinuxStatEvidence = + | { status: 'present'; value: string } + | { status: 'missing' } + | { status: 'unavailable' } + +export type WindowsProcessEvidence = + | { status: 'present'; commandLine: string | null; startedAtMs: number | null } + | { status: 'missing' } + | { status: 'unavailable' } + +export type DaemonProcessProbeDependencies = { + platform?: NodeJS.Platform + signalProcess?: (pid: number) => ProcessSignalEvidence + readLinuxStat?: (pid: number) => Promise + readBootIdentity?: () => Promise + readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise + readProcessStartedAtMs?: (pid: number) => number | null + queryWindowsProcess?: (pid: number) => Promise +} diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts new file mode 100644 index 00000000000..0a319a346d7 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DAEMON_GONE_PROOFS, + probeDaemonProcessIdentity, + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation +} from './daemon-incarnation-evidence' +import { + classifyDaemonAuditFailure, + recordAuthenticatedInventory, + type DaemonAuditClassifierDependencies, + type DaemonAuditContext +} from './daemon-audit-classifier' + +const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' } +const exactIncarnation: ExactDaemonIncarnation = { + identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' }, + linuxStartTicks: '4242', + bootId: 'boot-a' +} +const auditClassifierDependencies = { + probeProcessIdentity: async () => ({ + state: 'unknown', + reason: 'inspection_failed', + evidenceSources: ['process_signal'] + }), + inspectEndpointState: async (context) => + context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'missing' +} satisfies DaemonAuditClassifierDependencies + +function linuxStat(state: string, startTicks: string): string { + return `42 (orca daemon with spaces) ${[state, ...Array(18).fill('0'), startTicks].join(' ')}` +} + +function linuxDependencies( + overrides: DaemonProcessProbeDependencies = {} +): DaemonProcessProbeDependencies { + return { + platform: 'linux', + signalProcess: () => 'occupied', + readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4242') }), + readBootIdentity: async () => 'boot-a', + readCommandLine: async () => + `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`, + ...overrides + } +} + +describe('daemon process identity evidence', () => { + it('pins the dated conclusive-gone oracle contract', () => { + expect(DAEMON_GONE_PROOFS).toEqual({ + linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'], + darwin: ['pid_missing'], + win32: [ + 'windows_process_missing', + 'windows_creation_time_mismatch', + 'windows_named_pipe_missing' + ] + }) + }) + + it('proves Linux pid reuse from native start ticks without derived milliseconds', async () => { + const readProcessStartedAtMs = vi.fn(() => exactIncarnation.identity.startedAtMs) + + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4243') }), + readProcessStartedAtMs + }) + ) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'linux_start_ticks_mismatch' + }) + expect(readProcessStartedAtMs).not.toHaveBeenCalled() + }) + + it('proves Linux reboot invalidated the recorded incarnation', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ readBootIdentity: async () => 'boot-b' }) + ) + ).resolves.toMatchObject({ state: 'gone', reason: 'linux_boot_changed' }) + }) + + it('treats a matching unreaped Linux zombie as gone', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'present', value: linuxStat('Z', '4242') }) + }) + ) + ).resolves.toMatchObject({ state: 'gone', reason: 'linux_zombie' }) + }) + + it('keeps EPERM unknown without an independent identity match', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + signalProcess: () => 'permission_denied', + readLinuxStat: async () => ({ status: 'unavailable' }) + }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' }) + }) + + it('keeps an unreadable command line unknown even when Linux start identity matches', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ readCommandLine: async () => undefined }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'command_line_unavailable' }) + }) + + it('never treats signal-zero success alone as present', async () => { + await expect( + probeDaemonProcessIdentity( + { identity: exactIncarnation.identity }, + endpoint, + linuxDependencies({ + readCommandLine: async () => undefined + }) + ) + ).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('never turns a legacy identity gap into gone', async () => { + await expect( + probeDaemonProcessIdentity(null, endpoint, linuxDependencies()) + ).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' }) + }) + + it('uses Windows CreationDate as the primary identity regardless of command line', async () => { + const base = { + platform: 'win32' as const, + signalProcess: () => 'occupied' as const + } + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'unreadable', + startedAtMs: + exactIncarnation.identity.startedAtMs + WINDOWS_CREATION_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'windows_creation_time_mismatch' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: null, + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'unrelated process', + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: 'node daemon-entry', + startedAtMs: null + }) + }) + ).resolves.toMatchObject({ + state: 'unknown', + reason: 'windows_process_start_time_unavailable' + }) + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + ...base, + queryWindowsProcess: async () => ({ status: 'missing' }) + }) + ).resolves.toMatchObject({ + state: 'gone', + reason: 'windows_process_missing' + }) + }) + + it('requires Windows CIM evidence even when signal-zero reports a missing pid', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'win32', + signalProcess: () => 'missing', + queryWindowsProcess: async () => ({ + status: 'present', + commandLine: null, + startedAtMs: exactIncarnation.identity.startedAtMs + }) + }) + ).resolves.toMatchObject({ + state: 'present', + reason: 'windows_identity_match' + }) + }) + + it('keeps a macOS lstart mismatch unknown', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'darwin', + signalProcess: () => 'occupied', + readCommandLine: async () => + `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`, + readProcessStartedAtMs: () => exactIncarnation.identity.startedAtMs + 2_500 + }) + ).resolves.toMatchObject({ state: 'unknown', reason: 'macos_start_time_mismatch' }) + }) + + it('accepts ESRCH as conclusive POSIX process disappearance', async () => { + await expect( + probeDaemonProcessIdentity(exactIncarnation, endpoint, { + platform: 'darwin', + signalProcess: () => 'missing' + }) + ).resolves.toMatchObject({ state: 'gone', reason: 'pid_missing' }) + }) +}) + +describe('daemon audit availability evidence', () => { + const context: DaemonAuditContext = { + protocolGeneration: 23, + provider: 'local-daemon', + endpoint: endpoint.socketPath, + tokenPath: endpoint.tokenPath, + endpointKind: 'unix-socket', + profileScope: '/profile' + } + + it('records authoritative inventory as present without endpoint identity', () => { + expect(recordAuthenticatedInventory(context, null)).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + exactIncarnation: null, + inventoryAuthority: 'authoritative' + }) + }) + + it('represents failed legacy inventory as unknown, never an empty process list', async () => { + const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, { + dependencies: auditClassifierDependencies + }) + + expect(observation).toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + inventoryAuthority: 'unavailable', + processLiveness: 'unknown' + }) + expect(observation.evidenceSources.length).toBeGreaterThan(0) + expect(observation).not.toHaveProperty('processes') + }) + + it('keeps token removal after disconnect as contributing evidence only', async () => { + const observation = await classifyDaemonAuditFailure( + context, + 'token_missing_after_authenticated_disconnect', + null, + { + additionalEvidenceSources: ['token_file'], + dependencies: auditClassifierDependencies + } + ) + + expect(observation.state).toBe('unknown') + expect(observation.evidenceSources).toContain('token_file') + }) + + it('accepts Windows named-pipe absence only with exact incarnation evidence', async () => { + const windowsContext: DaemonAuditContext = { + ...context, + endpoint: '\\\\?\\pipe\\orca-daemon', + endpointKind: 'windows-named-pipe' + } + const observation = await classifyDaemonAuditFailure( + windowsContext, + 'inventory_failed', + exactIncarnation, + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } + ) + + expect(observation).toMatchObject({ + state: 'gone', + reason: 'windows_named_pipe_missing', + endpointState: 'missing', + exactIncarnation + }) + }) + + it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => { + const observation = await classifyDaemonAuditFailure( + context, + 'inventory_failed', + exactIncarnation, + { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies: auditClassifierDependencies + } + ) + + expect(observation).toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'unknown' + }) + }) +}) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts new file mode 100644 index 00000000000..314254cd539 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -0,0 +1,267 @@ +import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity' +import { + commandLineMatchesDaemon, + getProcessStartedAtMs, + startTimesWithinTolerance +} from './daemon-health' +import { + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation, + type ProcessSignalEvidence +} from './daemon-incarnation-evidence-types' +import { + inspectProcessSignal, + queryWindowsProcess, + readLinuxStat, + readProcessCommandLine +} from './daemon-process-inspection' + +export { + DAEMON_GONE_PROOFS, + WINDOWS_CREATION_TIME_TOLERANCE_MS, + type DaemonEvidenceSource, + type DaemonEvidenceSources, + type DaemonProcessEvidence, + type DaemonProcessProbeDependencies, + type ExactDaemonIncarnation, + type LinuxStatEvidence, + type ProcessSignalEvidence, + type WindowsProcessEvidence +} from './daemon-incarnation-evidence-types' + +const POSIX_START_TIME_TOLERANCE_MS = 1_500 + +export async function probeDaemonProcessIdentity( + exactIncarnation: ExactDaemonIncarnation | null, + endpoint: { socketPath: string; tokenPath: string }, + dependencies: DaemonProcessProbeDependencies = {} +): Promise { + if (!exactIncarnation) { + return unknown('exact_identity_unavailable', ['pid_record']) + } + const platform = dependencies.platform ?? process.platform + const signalProcess = dependencies.signalProcess ?? inspectProcessSignal + const signal = signalProcess(exactIncarnation.identity.pid) + if (platform !== 'win32' && signal === 'missing') { + return gone('pid_missing', ['process_signal', 'endpoint_identity'], exactIncarnation) + } + + if (platform === 'linux') { + return await probeLinuxProcess(exactIncarnation, endpoint, signal, dependencies) + } + if (platform === 'win32') { + return await probeWindowsProcess(exactIncarnation, endpoint, signal, dependencies) + } + return await probeMacosProcess(exactIncarnation, endpoint, signal, dependencies) +} + +async function probeLinuxProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid) + if (stat.status === 'missing') { + return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation) + } + if (stat.status === 'unavailable') { + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'linux_proc_stat', + 'process_signal' + ]) + } + + const expectedTicks = exactIncarnation.linuxStartTicks + const expectedBootId = exactIncarnation.bootId + if (expectedTicks || expectedBootId) { + if (!expectedTicks || !expectedBootId) { + return unknown('linux_identity_incomplete', ['pid_record']) + } + const bootId = await (dependencies.readBootIdentity ?? readBootIdentity)() + if (!bootId) { + return unknown('inspection_failed', ['boot_identity']) + } + if (bootId !== expectedBootId) { + return gone( + 'linux_boot_changed', + ['boot_identity', 'pid_record', 'endpoint_identity'], + exactIncarnation + ) + } + const currentTicks = parseLinuxStartTicks(stat.value) + if (!currentTicks) { + return unknown('inspection_failed', ['linux_proc_stat']) + } + if (currentTicks !== expectedTicks) { + return gone( + 'linux_start_ticks_mismatch', + ['linux_proc_stat', 'boot_identity', 'pid_record'], + exactIncarnation + ) + } + if (parseLinuxProcessState(stat.value) === 'Z') { + return gone( + 'linux_zombie', + ['linux_proc_stat', 'boot_identity', 'pid_record'], + exactIncarnation + ) + } + } + + const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)( + exactIncarnation.identity.pid, + 'linux' + ) + if (commandLine === undefined) { + return unknown('command_line_unavailable', ['process_command_line']) + } + if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) { + return unknown('command_line_mismatch', ['process_command_line']) + } + if (expectedTicks && expectedBootId) { + return present('linux_identity_match', [ + 'linux_proc_stat', + 'boot_identity', + 'process_command_line', + 'endpoint_identity' + ]) + } + + const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)( + exactIncarnation.identity.pid + ) + if (startedAtMs === null) { + return unknown('process_start_time_unavailable', ['process_start_time']) + } + return startTimesWithinTolerance( + startedAtMs, + exactIncarnation.identity.startedAtMs, + POSIX_START_TIME_TOLERANCE_MS + ) + ? present('linux_identity_match', [ + 'process_command_line', + 'process_start_time', + 'endpoint_identity' + ]) + : unknown('linux_identity_incomplete', ['process_start_time', 'endpoint_identity']) +} + +async function probeMacosProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)( + exactIncarnation.identity.pid, + 'darwin' + ) + if (commandLine === undefined) { + return unknown( + signal === 'permission_denied' ? 'permission_denied' : 'command_line_unavailable', + ['process_command_line', 'process_signal'] + ) + } + if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) { + return unknown('command_line_mismatch', ['process_command_line']) + } + const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)( + exactIncarnation.identity.pid + ) + if (startedAtMs === null) { + return unknown('process_start_time_unavailable', ['process_start_time']) + } + return startTimesWithinTolerance( + startedAtMs, + exactIncarnation.identity.startedAtMs, + POSIX_START_TIME_TOLERANCE_MS + ) + ? present('macos_identity_match', [ + 'process_command_line', + 'process_start_time', + 'endpoint_identity' + ]) + : unknown('macos_start_time_mismatch', ['process_start_time', 'endpoint_identity']) +} + +async function probeWindowsProcess( + exactIncarnation: ExactDaemonIncarnation, + endpoint: { socketPath: string; tokenPath: string }, + signal: ProcessSignalEvidence, + dependencies: DaemonProcessProbeDependencies +): Promise { + const identity = await (dependencies.queryWindowsProcess ?? queryWindowsProcess)( + exactIncarnation.identity.pid + ) + if (identity.status === 'missing') { + return gone('windows_process_missing', ['windows_cim', 'endpoint_identity'], exactIncarnation) + } + if (identity.status === 'unavailable') { + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'windows_cim', + 'process_signal' + ]) + } + if (identity.startedAtMs === null) { + return unknown('windows_process_start_time_unavailable', ['windows_cim']) + } + if ( + !startTimesWithinTolerance( + identity.startedAtMs, + exactIncarnation.identity.startedAtMs, + WINDOWS_CREATION_TIME_TOLERANCE_MS + ) + ) { + return gone( + 'windows_creation_time_mismatch', + ['windows_cim', 'endpoint_identity'], + exactIncarnation + ) + } + return present( + 'windows_identity_match', + identity.commandLine && + commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath) + ? ['windows_cim', 'process_command_line', 'endpoint_identity'] + : ['windows_cim', 'endpoint_identity'] + ) +} + +export function parseLinuxProcessState(statLine: string): string | null { + const commandEnd = statLine.lastIndexOf(')') + if (commandEnd < 0) { + return null + } + return ( + statLine + .slice(commandEnd + 1) + .trim() + .split(/\s+/, 1)[0] ?? null + ) +} + +function present( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'present', reason, evidenceSources } +} + +function gone( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources, + exactIncarnation: ExactDaemonIncarnation +): DaemonProcessEvidence { + return { state: 'gone', reason, evidenceSources, exactIncarnation } +} + +function unknown( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'unknown', reason, evidenceSources } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 77881045cb0..444627f59e6 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -241,7 +241,10 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock })) -vi.mock('child_process', () => ({ fork: forkMock })) +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal>()), + fork: forkMock +})) vi.mock('net', () => ({ connect: netConnectMock })) @@ -1754,7 +1757,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { on(event: string, cb: (arg?: unknown) => void) { handlers[event]?.push(cb) if (event === 'message') { - queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 })) + queueMicrotask(() => + cb({ + type: 'ready', + startedAtMs: 1_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ) } return this }, @@ -1782,6 +1792,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(JSON.parse(pidContents as string)).toEqual({ pid: 12345, startedAtMs: 1_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a', entryPath: FAKE_DAEMON_ENTRY_PATH, appVersion: '1.2.3', launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 24fc9ef4dd2..d7fae082341 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -53,6 +53,7 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' +import { parseDaemonReadyIdentity } from './daemon-ready-identity' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -600,14 +601,8 @@ function createOutOfProcessLauncher( if (settled) { return } - const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs - if ( - !Number.isSafeInteger(child.pid) || - (child.pid as number) <= 0 || - typeof selfReported !== 'number' || - !Number.isFinite(selfReported) || - selfReported <= 0 - ) { + const readyIdentity = parseDaemonReadyIdentity(msg) + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) { void fail(new Error('Daemon readiness identity is incomplete')) return } @@ -617,7 +612,7 @@ function createOutOfProcessLauncher( pidPath, serializeDaemonPidFile({ pid: child.pid as number, - startedAtMs: selfReported, + ...readyIdentity, entryPath, appVersion: app.getVersion(), launchNonce @@ -718,7 +713,9 @@ export async function initDaemonPtyProvider( // Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive). const abortedStartupAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, - tokenPath: info.tokenPath + tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir }) releaseDaemonAdoptionLease(newSpawner.getHandle()) await abortedStartupAdapter.disconnectOnly() @@ -728,6 +725,8 @@ export async function initDaemonPtyProvider( const newAdapter = new DaemonPtyAdapter({ socketPath: info.socketPath, tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir, historyPath: getHistoryDir(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { @@ -925,6 +924,8 @@ async function runRestartDaemon(): Promise { const newCurrent = new DaemonPtyAdapter({ socketPath: info.socketPath, tokenPath: info.tokenPath, + pidPath: getDaemonPidPath(runtimeDir), + profileScope: runtimeDir, historyPath: getHistoryDir(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the @@ -1155,6 +1156,8 @@ export async function createLegacyDaemonAdapters( new DaemonPtyAdapter({ socketPath, tokenPath, + pidPath: getDaemonPidPath(runtimeDir, protocolVersion), + profileScope: runtimeDir, protocolVersion, historyPath }) diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts new file mode 100644 index 00000000000..2b612fd068c --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection' + +describe('daemon process inspection', () => { + it('falls back to ps when Linux procfs returns an empty command line', async () => { + const readTextFile = vi.fn(async () => '') + const runCommand = vi.fn(async () => 'node daemon-entry --socket daemon.sock') + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node daemon-entry --socket daemon.sock' + ) + expect(readTextFile).toHaveBeenCalledWith('/proc/42/cmdline') + expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'command='], 2_000) + }) + + it('uses a non-empty Linux procfs command line without spawning ps', async () => { + const readTextFile = vi.fn(async () => 'node\0daemon-entry') + const runCommand = vi.fn() + + await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe( + 'node\0daemon-entry' + ) + expect(runCommand).not.toHaveBeenCalled() + }) + + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( + 'rejects unsafe Windows pid %s before command interpolation', + async (pid) => { + const runCommand = vi.fn() + + await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({ + status: 'unavailable' + }) + expect(runCommand).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts new file mode 100644 index 00000000000..6dbc8ed55c4 --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.ts @@ -0,0 +1,122 @@ +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { promisify } from 'node:util' +import type { + LinuxStatEvidence, + ProcessSignalEvidence, + WindowsProcessEvidence +} from './daemon-incarnation-evidence-types' + +const execFileAsync = promisify(execFile) + +export type DaemonProcessInspectionDependencies = { + readTextFile?: (path: string) => Promise + runCommand?: (file: string, args: string[], timeoutMs: number) => Promise +} + +export function inspectProcessSignal(pid: number): ProcessSignalEvidence { + try { + process.kill(pid, 0) + return 'occupied' + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return 'missing' + } + if (hasErrorCode(error, 'EPERM')) { + return 'permission_denied' + } + return 'unavailable' + } +} + +export async function readLinuxStat(pid: number): Promise { + try { + return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') } + } catch (error) { + return { status: hasErrorCode(error, 'ENOENT') ? 'missing' : 'unavailable' } + } +} + +export async function readProcessCommandLine( + pid: number, + platform: NodeJS.Platform, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + const readTextFile = + dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8')) + const runCommand = dependencies.runCommand ?? runInspectionCommand + if (platform === 'linux') { + try { + const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`) + if (procCommandLine.length > 0) { + return procCommandLine + } + } catch { + // Fall through to ps for procfs privilege or mount restrictions. + } + } + try { + const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000) + return stdout.trim() || undefined + } catch { + return undefined + } +} + +export async function queryWindowsProcess( + pid: number, + dependencies: DaemonProcessInspectionDependencies = {} +): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return { status: 'unavailable' } + } + const runCommand = dependencies.runCommand ?? runInspectionCommand + try { + const stdout = await runCommand( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + + `if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` + + `$start = $null; if ($p.CreationDate) { ` + + `$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` + + `@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }` + ], + 3_000 + ) + const parsed = JSON.parse(stdout.trim()) as { + exists?: unknown + cmd?: unknown + start?: unknown + } + if (parsed.exists === false) { + return { status: 'missing' } + } + if (parsed.exists !== true) { + return { status: 'unavailable' } + } + return { + status: 'present', + commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null, + startedAtMs: + typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null + } + } catch { + return { status: 'unavailable' } + } +} + +async function runInspectionCommand( + file: string, + args: string[], + timeoutMs: number +): Promise { + const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs }) + return stdout +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index cadee006fc0..69ed1bdbf75 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1449,6 +1449,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(procs[0]).toHaveProperty('title') expect(procs[0].cwd).toBe('/repo/owned-before-osc7') expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7') + expect(adapter.getLastAuditObservation()).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory', + inventoryAuthority: 'authoritative' + }) }) it('reports the daemon session WSL owner', async () => { @@ -1470,6 +1475,104 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } } }) + + it('retains authenticated identity and reports replacement across a same-endpoint reconnect', async () => { + await adapter.listProcesses() + const firstIdentity = adapter.getLastAuthenticatedDaemonIdentity() + expect(firstIdentity).not.toBeNull() + const identityChanges: { + previous: NonNullable + current: NonNullable + }[] = [] + adapter.onDaemonIdentityChanged(() => { + throw new Error('audit listener failed') + }) + adapter.onDaemonIdentityChanged((event) => identityChanges.push(event)) + + await server.shutdown() + await waitFor( + () => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected() + ) + expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(firstIdentity) + server = new DaemonServer({ + socketPath, + tokenPath, + launchNonce: 'replacement-launch', + startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000, + log: daemonLog, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(identityChanges).toEqual([ + { + previous: firstIdentity, + current: { + pid: process.pid, + startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000, + launchNonce: 'replacement-launch' + } + } + ]) + expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current) + }) + + it('isolates audit observation listeners from inventory and later listeners', async () => { + const laterListener = vi.fn() + adapter.onAuditEligibilityObservation(() => { + throw new Error('audit listener failed') + }) + adapter.onAuditEligibilityObservation(laterListener) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(laterListener).toHaveBeenCalledOnce() + expect(laterListener).toHaveBeenCalledWith( + expect.objectContaining({ + state: 'present', + reason: 'authenticated_inventory' + }) + ) + expect(adapter.getLastAuditObservation()).toMatchObject({ + state: 'present', + reason: 'authenticated_inventory' + }) + }) + + it('audits token ENOENT only after an authenticated disconnect', async () => { + const observations: { + trigger: string + state: string + evidenceSources: readonly string[] + }[] = [] + adapter.onAuditEligibilityObservation((observation) => observations.push(observation)) + await adapter.listProcesses() + + await server.shutdown() + await waitFor( + () => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected() + ) + await expect(adapter.listProcesses()).rejects.toThrow() + await waitFor(() => + observations.some( + (observation) => observation.trigger === 'token_missing_after_authenticated_disconnect' + ) + ) + + expect(observations).toContainEqual( + expect.objectContaining({ + trigger: 'token_missing_after_authenticated_disconnect', + state: 'unknown', + evidenceSources: expect.arrayContaining(['token_file']) + }) + ) + }) }) describe('hasChildProcesses / getForegroundProcess', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 4be7931bee7..c5cc304bd10 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,8 +1,12 @@ /* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ import { basename } from 'node:path' -import { existsSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { DaemonClient } from './client' -import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { + getMacDaemonSystemResolverHealth, + parseDaemonPidFile, + type ParsedDaemonPid +} from './daemon-health' import { HistoryManager, type HistoryCheckpointResult, @@ -63,6 +67,16 @@ import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' import { NdjsonLineTooLongError } from './ndjson' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import { + classifyDaemonAuditFailure, + recordAuthenticatedInventory, + type DaemonAuditContext, + type DaemonAuditObservation, + type DaemonAuditTrigger +} from './daemon-audit-classifier' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event' type PendingDaemonSpawnOperation = { exitsBySessionId: Map @@ -100,6 +114,8 @@ function providerSequenceForSpawn( export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string + pidPath?: string + profileScope?: string protocolVersion?: number /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ historyPath?: string @@ -109,6 +125,11 @@ export type DaemonPtyAdapterOptions = { export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver' +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + const MAX_TOMBSTONES = 1000 const MAX_CONCURRENT_CHECKPOINTS = 4 @@ -130,7 +151,14 @@ export class DaemonPtyAdapter implements IPtyProvider { readonly protocolVersion: number private socketPath: string private tokenPath: string + private pidPath: string | null private client: DaemonClient + private auditContext: DaemonAuditContext + private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null + private exactDaemonIncarnation: ExactDaemonIncarnation | null = null + private lastAuditObservation: DaemonAuditObservation | null = null + private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] + private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] private historyManager: HistoryManager | null private historyReader: HistoryReader | null private respawnFn: DaemonPtyAdapterOptions['respawn'] | null @@ -219,6 +247,15 @@ export class DaemonPtyAdapter implements IPtyProvider { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.auditContext = { + protocolGeneration: this.protocolVersion, + provider: 'local-daemon', + endpoint: opts.socketPath, + tokenPath: opts.tokenPath, + endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', + profileScope: opts.profileScope ?? '' + } this.client = new DaemonClient({ socketPath: opts.socketPath, tokenPath: opts.tokenPath, @@ -247,6 +284,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.producerResumesOwedOnReconnect.add(id) } this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') }) } @@ -254,6 +292,26 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.historyManager } + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { + return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null + } + + getLastAuditObservation(): DaemonAuditObservation | null { + return this.lastAuditObservation + } + + onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { + this.identityChangeListeners.push(listener) + return () => removeListener(this.identityChangeListeners, listener) + } + + onAuditEligibilityObservation( + listener: (observation: DaemonAuditObservation) => void + ): () => void { + this.auditObservationListeners.push(listener) + return () => removeListener(this.auditObservationListeners, listener) + } + supportsAgentSessionClaims(): boolean { return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION } @@ -1262,36 +1320,57 @@ export class DaemonPtyAdapter implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: connect + listSessions share the caller's one absolute deadline so a - // wedged handshake cannot burn the whole teardown budget before the list issues. - await this.ensureConnected(opts?.deadlineMs) - const result = await this.client.request( - 'listSessions', - undefined, - remainingRequestTimeoutMs(opts?.deadlineMs) - ) - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - const { worktreeId } = parsePtySessionId(session.sessionId) - processes.push( - admission.admit({ - id: session.sessionId, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. - cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', - ...(worktreeId ? { worktreeId } : {}), - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - }) + try { + // Why: connect + listSessions share the caller's one absolute deadline so a + // wedged handshake cannot burn the whole teardown budget before the list issues. + await this.ensureConnected(opts?.deadlineMs) + const result = await this.client.request( + 'listSessions', + undefined, + remainingRequestTimeoutMs(opts?.deadlineMs) ) + const admission = new PtyProcessListAdmission() + const processes: PtyProcessInfo[] = [] + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + const { worktreeId } = parsePtySessionId(session.sessionId) + processes.push( + admission.admit({ + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. + cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}), + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + }) + ) + } + this.publishAuditObservation( + recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) + ) + return processes + } catch (error) { + const missingAuthenticatedToken = + isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect() + const missingNamedPipe = isMissingWindowsNamedPipeError(error) + this.observeAuditFailure( + missingAuthenticatedToken + ? 'token_missing_after_authenticated_disconnect' + : 'inventory_failed', + this.exactDaemonIncarnation, + [ + ...(missingAuthenticatedToken ? (['token_file'] as const) : []), + ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) + ], + missingNamedPipe ? 'windows_named_pipe_missing' : undefined + ) + throw error } - return processes } private validatedAgentSessionOwners( @@ -1452,6 +1531,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.wslDistrosBySessionId.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() + this.auditObservationListeners.length = 0 + this.identityChangeListeners.length = 0 this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions @@ -1470,6 +1551,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. await this.client.ensureConnected() + this.recordAuthenticatedIdentity() } // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, @@ -1532,6 +1614,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. this.releasePendingRespawnAdoptionLease() } + this.recordAuthenticatedIdentity() // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. const isFreshConnection = this.removeEventListener === null @@ -1543,6 +1626,73 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private recordAuthenticatedIdentity(): void { + const current = this.client.getDaemonIdentity() + if (!current) { + return + } + const previous = this.lastAuthenticatedIdentity + if (previous && sameEndpointIdentity(previous, current)) { + return + } + const previousExactIncarnation = this.exactDaemonIncarnation + const pidRecord = this.readMatchingPidRecord(current) + this.lastAuthenticatedIdentity = { ...current } + this.exactDaemonIncarnation = { + identity: { ...current }, + ...(pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } + if (!previous) { + return + } + const event = { previous: { ...previous }, current: { ...current } } + notifyAuditListeners(this.identityChangeListeners, event) + this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [ + 'endpoint_identity' + ]) + } + + private readMatchingPidRecord(identity: DaemonEndpointIdentity): ParsedDaemonPid | null { + if (!this.pidPath) { + return null + } + try { + const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8')) + return parsed?.pid === identity.pid && + parsed.startedAtMs === identity.startedAtMs && + parsed.launchNonce === identity.launchNonce + ? parsed + : null + } catch { + return null + } + } + + private observeAuditFailure( + trigger: Exclude, + exactIncarnation = this.exactDaemonIncarnation, + additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void { + void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + .then((observation) => this.publishAuditObservation(observation)) + .catch(() => {}) + } + + private publishAuditObservation(observation: DaemonAuditObservation): void { + this.lastAuditObservation = observation + trackDaemonAuditEligibility(observation) + notifyAuditListeners(this.auditObservationListeners, observation) + } + private resyncBackgroundedSessions(): void { for (const id of this.backgroundedSessionIds) { // Harmless no-op for sessions the daemon doesn't know (yet). @@ -1829,6 +1979,13 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon. const missingRetiredEndpointToken = isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect() + if (missingRetiredEndpointToken) { + this.observeAuditFailure( + 'token_missing_after_authenticated_disconnect', + this.exactDaemonIncarnation, + ['token_file'] + ) + } if ( this.respawnAdoptionClosed || !this.respawnFn || @@ -2135,6 +2292,34 @@ export class DaemonPtyAdapter implements IPtyProvider { } } +function sameEndpointIdentity( + left: DaemonEndpointIdentity, + right: DaemonEndpointIdentity +): boolean { + return ( + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce + ) +} + +function removeListener(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} + // Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); // message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. function isDaemonGoneError(err: unknown): boolean { @@ -2161,3 +2346,11 @@ function isMissingTokenFileError(err: unknown): boolean { const errno = err as NodeJS.ErrnoException return errno.code === 'ENOENT' && errno.syscall === 'open' } + +function isMissingWindowsNamedPipeError(err: unknown): boolean { + if (process.platform !== 'win32' || !(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'connect' +} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 44df6bbaf33..17ad0dd835c 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -546,6 +546,35 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') }) + it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => { + const current = createAdapter('current', ['current-session']) + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited')) + + await expect(router.listProcesses()).rejects.toThrow('legacy exited') + await expect(router.listProcesses()).rejects.toThrow('legacy exited') + expect(router.getLegacyAdapters()).toEqual([legacy]) + expect(current.listProcesses).toHaveBeenCalledTimes(2) + }) + + it('pins colliding unmapped legacy ids falling through to the current daemon', async () => { + const sessionId = 'cross-generation-collision' + const current = createAdapter('current', [sessionId]) + const legacy = createAdapter('legacy', [sessionId]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy discovery failed')) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + await router.discoverLegacySessions() + router.write(sessionId, 'misrouted\n') + + expect(current.write).toHaveBeenCalledWith(sessionId, 'misrouted\n') + expect(legacy.write).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('merges startup reconciliation and updates route mappings', async () => { const current = createAdapter('current', [], { alive: ['current-alive'], diff --git a/src/main/daemon/daemon-ready-identity.test.ts b/src/main/daemon/daemon-ready-identity.test.ts new file mode 100644 index 00000000000..a2ffb2022bf --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { parseDaemonReadyIdentity } from './daemon-ready-identity' + +describe('parseDaemonReadyIdentity', () => { + it('accepts additive Linux incarnation identity', () => { + expect( + parseDaemonReadyIdentity({ + type: 'ready', + startedAtMs: 1_700_000_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ).toEqual({ + startedAtMs: 1_700_000_000_000, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + }) + + it('keeps older ready payloads compatible', () => { + expect(parseDaemonReadyIdentity({ type: 'ready', startedAtMs: 123 })).toEqual({ + startedAtMs: 123 + }) + }) + + it('rejects partial Linux identity instead of persisting a false proof', () => { + expect( + parseDaemonReadyIdentity({ + type: 'ready', + startedAtMs: 123, + linuxStartTicks: '4242' + }) + ).toBeNull() + }) +}) diff --git a/src/main/daemon/daemon-ready-identity.ts b/src/main/daemon/daemon-ready-identity.ts new file mode 100644 index 00000000000..49a02bffdd0 --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity' + +export type DaemonReadyIdentity = { + startedAtMs: number + linuxStartTicks?: string + bootId?: string +} + +export async function readCurrentDaemonReadyIdentity( + startedAtMs: number +): Promise { + if (process.platform !== 'linux') { + return { startedAtMs } + } + try { + const linuxStartTicks = parseLinuxStartTicks(readFileSync('/proc/self/stat', 'utf8')) + const bootId = await readBootIdentity() + return linuxStartTicks && bootId ? { startedAtMs, linuxStartTicks, bootId } : { startedAtMs } + } catch { + return { startedAtMs } + } +} + +export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null { + if (!message || typeof message !== 'object') { + return null + } + const value = message as { + startedAtMs?: unknown + linuxStartTicks?: unknown + bootId?: unknown + } + if ( + typeof value.startedAtMs !== 'number' || + !Number.isFinite(value.startedAtMs) || + value.startedAtMs <= 0 + ) { + return null + } + const hasLinuxStartTicks = value.linuxStartTicks !== undefined + const hasBootId = value.bootId !== undefined + if (hasLinuxStartTicks !== hasBootId) { + return null + } + if (!hasLinuxStartTicks) { + return { startedAtMs: value.startedAtMs } + } + if ( + typeof value.linuxStartTicks !== 'string' || + value.linuxStartTicks.length === 0 || + typeof value.bootId !== 'string' || + value.bootId.length === 0 + ) { + return null + } + return { + startedAtMs: value.startedAtMs, + linuxStartTicks: value.linuxStartTicks, + bootId: value.bootId + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ba0ea239c67..68b2a5afce4 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -14,6 +14,8 @@ export type DaemonPidFile = { entryPath?: string appVersion?: string launchNonce?: string + linuxStartTicks?: string + bootId?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 79c71945cbf..fbbfdfe4408 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -405,4 +405,19 @@ describe('DegradedDaemonPtyProvider', () => { expect(provider.getCurrentDaemonSessionIds()).toEqual([]) expect(provider.hasPty('legacy-session')).toBe(true) }) + + it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => { + const current = createDaemonAdapter('daemon', ['current-session']) + const legacy = createDaemonAdapter('legacy', ['legacy-session']) + const fallback = createProvider('fallback', ['fallback-session']) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) + await provider.discoverDaemonSessions() + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited')) + + await expect(provider.listProcesses()).rejects.toThrow('legacy exited') + await expect(provider.listProcesses()).rejects.toThrow('legacy exited') + expect(provider.getLegacyAdapters()).toEqual([legacy]) + expect(current.listProcesses).toHaveBeenCalledTimes(3) + expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 12edb3b8a7c..490f15124d7 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -58,14 +58,16 @@ describe('createProductionLauncher', () => { it('returns a launcher function', () => { const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js' + getDaemonEntryPath: () => '/fake/path.js', + getAppVersion: () => '1.2.3' }) expect(typeof launcher).toBe('function') }) it('rejects either ownership argument without its pair before forking', async () => { const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js' + getDaemonEntryPath: () => '/fake/path.js', + getAppVersion: () => '1.2.3' }) await expect( @@ -126,12 +128,18 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const pidPath = join(dir, 'daemon.pid') const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + handlers.message[0]?.({ + type: 'ready', + startedAtMs: 123_456, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) const handle = await launch expect(handle.shutdown).toEqual(expect.any(Function)) @@ -143,7 +151,10 @@ describe('createProductionLauncher', () => { expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ pid: 12345, startedAtMs: 123_456, + linuxStartTicks: '4242', + bootId: 'boot-a', entryPath: join(dir, 'daemon-entry.js'), + appVersion: '1.2.3', launchNonce: 'launch-a' }) expect(forkMock).toHaveBeenCalledWith( @@ -194,7 +205,8 @@ describe('createProductionLauncher', () => { } forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) @@ -241,7 +253,8 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) @@ -302,7 +315,8 @@ describe('createProductionLauncher', () => { forkMock.mockReturnValueOnce(child) const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) handlers.message[0]?.({ type: 'ready' }) @@ -359,7 +373,8 @@ describe('createProductionLauncher', () => { const pidPath = join(dir, 'occupied.pid') writeFileSync(pidPath, 'occupied') const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js') + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' }) const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index b9fe704a41d..79bf439eead 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -5,11 +5,13 @@ import { type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' +import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity' const READY_TIMEOUT_MS = 10_000 export type ProductionLauncherOptions = { getDaemonEntryPath: () => string + getAppVersion: () => string } export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { @@ -45,9 +47,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } ) - let startedAtMs: number + let readyIdentity: DaemonReadyIdentity try { - startedAtMs = await waitForReady(child) + readyIdentity = await waitForReady(child) } catch (error) { return rejectAfterChildCleanup(child, error) } @@ -60,8 +62,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo pidPath, serializeDaemonPidFile({ pid: child.pid as number, - startedAtMs, + ...readyIdentity, entryPath, + appVersion: opts.getAppVersion(), launchNonce }), { mode: 0o600, flag: 'wx' } @@ -81,7 +84,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } } -function waitForReady(child: ChildProcess): Promise { +function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined let settled = false @@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise { if (settled) { return } - const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs - if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) { + const readyIdentity = parseDaemonReadyIdentity(msg) + if (!readyIdentity) { fail(new Error('Daemon readiness identity is incomplete')) return } @@ -115,7 +118,7 @@ function waitForReady(child: ChildProcess): Promise { // Why: the daemon is detached after readiness, so startup listeners // must not keep the child process closure alive for the daemon lifetime. cleanupStartupListeners() - resolve(startedAtMs) + resolve(readyIdentity) } } function onError(err: Error): void { diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 55f33f9d085..eac1820a047 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -633,6 +633,7 @@ describe('killAllProcessesForWorktree', () => { // DaemonClient method so the shared connect+RPC budget path is exercised. ensureConnectedWithin: async () => {}, isConnected: () => true, + getDaemonIdentity: () => null, disconnect: () => {}, notify: () => {}, request: ( diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts new file mode 100644 index 00000000000..047f95e0665 --- /dev/null +++ b/src/shared/daemon-audit-eligibility.ts @@ -0,0 +1,81 @@ +export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const + +export const DAEMON_AUDIT_TRIGGER_VALUES = [ + 'endpoint_identity_changed', + 'inventory_answered', + 'inventory_failed', + 'token_missing_after_authenticated_disconnect', + 'transport_closed' +] as const + +export const DAEMON_EVIDENCE_SOURCE_VALUES = [ + 'authenticated_inventory', + 'boot_identity', + 'endpoint_identity', + 'endpoint_stat', + 'linux_proc_stat', + 'pid_record', + 'process_command_line', + 'process_signal', + 'process_start_time', + 'token_file', + 'windows_cim', + 'windows_named_pipe' +] as const + +export const DAEMON_PROCESS_PRESENT_REASON_VALUES = [ + 'linux_identity_match', + 'macos_identity_match', + 'windows_identity_match' +] as const + +export const DAEMON_PROCESS_GONE_REASON_VALUES = [ + 'linux_boot_changed', + 'linux_start_ticks_mismatch', + 'linux_zombie', + 'pid_missing', + 'windows_creation_time_mismatch', + 'windows_process_missing' +] as const + +export const DAEMON_PROCESS_UNKNOWN_REASON_VALUES = [ + 'command_line_mismatch', + 'command_line_unavailable', + 'exact_identity_unavailable', + 'inspection_failed', + 'linux_identity_incomplete', + 'macos_start_time_mismatch', + 'permission_denied', + 'process_start_time_unavailable', + 'windows_process_start_time_unavailable' +] as const + +export const DAEMON_AUDIT_GONE_REASON_VALUES = [ + ...DAEMON_PROCESS_GONE_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export const DAEMON_AUDIT_REASON_VALUES = [ + 'authenticated_inventory', + 'endpoint_identity_changed', + 'inventory_failed', + 'token_missing_after_authenticated_disconnect', + 'transport_closed', + ...DAEMON_AUDIT_GONE_REASON_VALUES +] as const + +export const DAEMON_AUDIT_PROCESS_REASON_VALUES = [ + ...DAEMON_PROCESS_PRESENT_REASON_VALUES, + ...DAEMON_PROCESS_GONE_REASON_VALUES, + ...DAEMON_PROCESS_UNKNOWN_REASON_VALUES, + 'windows_named_pipe_missing' +] as const + +export type DaemonAuditState = (typeof DAEMON_AUDIT_STATE_VALUES)[number] +export type DaemonAuditTrigger = (typeof DAEMON_AUDIT_TRIGGER_VALUES)[number] +export type DaemonAuditFailureTrigger = Exclude +export type DaemonEvidenceSource = (typeof DAEMON_EVIDENCE_SOURCE_VALUES)[number] +export type DaemonProcessPresentReason = (typeof DAEMON_PROCESS_PRESENT_REASON_VALUES)[number] +export type DaemonProcessGoneReason = (typeof DAEMON_PROCESS_GONE_REASON_VALUES)[number] +export type DaemonProcessUnknownReason = (typeof DAEMON_PROCESS_UNKNOWN_REASON_VALUES)[number] +export type DaemonAuditGoneReason = (typeof DAEMON_AUDIT_GONE_REASON_VALUES)[number] diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 007e48cc7cf..bcf92ef54bc 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -27,6 +27,13 @@ import { DAEMON_REPLACE_REASONS, DAEMON_RETIRE_REASONS } from './daemon-lifecycle-telemetry' +import { + DAEMON_AUDIT_PROCESS_REASON_VALUES, + DAEMON_AUDIT_REASON_VALUES, + DAEMON_AUDIT_STATE_VALUES, + DAEMON_AUDIT_TRIGGER_VALUES, + DAEMON_EVIDENCE_SOURCE_VALUES +} from './daemon-audit-eligibility' import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers' import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source' import { appStarSourceSchema } from './gh-star-source' @@ -418,6 +425,29 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [ .strict() ]) +const daemonAuditEligibilitySchema = z + .object({ + state: z.enum(DAEMON_AUDIT_STATE_VALUES), + reason: z.enum(DAEMON_AUDIT_REASON_VALUES), + trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES), + evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12), + protocol_generation: z.number().int().positive().max(1_000), + provider: z.literal('local-daemon'), + endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']), + profile_scope: z.enum(['configured', 'unspecified']), + exact_incarnation: z.enum([ + 'endpoint-identity', + 'endpoint-identity-linux-ticks', + 'unavailable' + ]), + reachability: z.enum(['authenticated', 'disconnected', 'unknown']), + inventory_authority: z.enum(['authoritative', 'unavailable']), + process_liveness: z.enum(['present', 'gone', 'unknown']), + process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(), + endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown']) + }) + .strict() + // Rollout signal for granting Codex hook trust via codex app-server RPCs // instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed` // spikes mean the RPC lane is not taking; steady-state ledger skips are not @@ -1412,6 +1442,7 @@ export const eventSchemas = { daemon_start_failed: daemonStartFailedSchema, main_thread_hang_detected: mainThreadHangDetectedSchema, daemon_lifecycle: daemonLifecycleSchema, + daemon_audit_eligibility: daemonAuditEligibilitySchema, runtime_rpc_start_failed: runtimeRpcStartFailedSchema, codex_trust_grant: codexTrustGrantSchema, From 9ec9233e4ee985968b1636f92d9a56e1e106c501 Mon Sep 17 00:00:00 2001 From: Hyein Cho <142622296+hyein-cbio@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:06:16 +0100 Subject: [PATCH 009/123] fix(status-bar): align usage density modes (#11376) * fix(status-bar): align usage density modes * fix(status-bar): reuse cataloged usage mode label --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../src/components/status-bar/StatusBar.tsx | 32 ++--- .../status-bar/UsageRosterPanel.test.tsx | 113 ++++++++++++++++++ .../status-bar/UsageRosterPanel.tsx | 79 ++++++++---- .../provider-segment-monthly-window.test.tsx | 15 +++ 4 files changed, 195 insertions(+), 44 deletions(-) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 02978a47912..9d7534e8c3b 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -968,7 +968,10 @@ function MiniBar({ display: UsagePercentageDisplay }): React.JSX.Element { return ( -
+
0) { @@ -1232,13 +1233,8 @@ function VerboseProviderUsage({ return window !== null }) - const meterWindow = p.session ?? p.weekly - return ( <> - {meterWindow && !compact ? ( - - ) : null} {visibleWindows.map((window, index) => ( {index > 0 ? · : null} @@ -1312,22 +1308,20 @@ export function ProviderSegment({ {mode === 'verbose' ? ( - - ) : ( <> {tightest && !compact ? ( ) : null} - {tightest ? ( - - ) : null} + - )} + ) : tightest ? ( + + ) : null} {isStale && } ) diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx index b1addca4dab..de4f5a0e06f 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.test.tsx @@ -134,6 +134,119 @@ describe('UsageRow', () => { expect(markup).toContain('wk') expect(markup).toContain('10%') }) + + it('renders the tightest window inline in compact mode', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-usage-mode="compact"') + expect(markup.match(/data-usage-window=/g)).toHaveLength(1) + expect(markup).not.toContain('data-usage-bar') + expect(markup).toContain('60%') + expect(markup).not.toContain('25%') + expect(markup).not.toContain('Resets in') + }) + + it('uses the same compact selection for Claude subscription windows', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/data-usage-window=/g)).toHaveLength(1) + expect(markup).not.toContain('data-usage-bar') + expect(markup).toContain('Fable') + expect(markup).toContain('75%') + expect(markup).not.toContain('25%') + expect(markup).not.toContain('60%') + }) + + it('renders every window below the header in verbose mode', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-usage-mode="verbose"') + expect(markup.match(/data-usage-window=/g)).toHaveLength(2) + expect(markup.match(/data-usage-bar/g)).toHaveLength(2) + expect(markup).toContain('25%') + expect(markup).toContain('60%') + }) }) describe('UsageRosterPanel density picker', () => { diff --git a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx index 1e675232228..feb066fadca 100644 --- a/src/renderer/src/components/status-bar/UsageRosterPanel.tsx +++ b/src/renderer/src/components/status-bar/UsageRosterPanel.tsx @@ -80,31 +80,60 @@ function soonestResetLabel(sections: UsageSection[], now: number): string | null return formatResetCountdown(Math.min(...resets) - now) } -// Presentational row: a compact header (icon · name · plan · reset) with -// the per-window metrics beneath, so the reset stays visible and multi-window -// agents stay short. The wrapper supplies padding + interaction (drill-in -// submenu or plain clickable row). +function UsageMetric({ + section, + label, + display, + showBar = true +}: { + section: UsageSection + label: string + display: UsagePercentageDisplay + showBar?: boolean +}): React.JSX.Element { + const used = clampUsedPercent(section.window.usedPercent) + const shown = getDisplayedUsagePercentage(section.window.usedPercent, display) + + return ( + + {label} + {showBar ? ( + + + + ) : null} + {shown}% + + ) +} + export function UsageRow({ p, display, state, showSignInAction, - now + now, + mode = 'verbose' }: { p: ProviderRateLimits display: UsagePercentageDisplay state: UsageRosterRowState showSignInAction: boolean now: number + mode?: StatusBarUsageMode }): React.JSX.Element { const sections = usedSections(p) const hasUsage = sections.length > 0 const name = getProviderDisplayName(p.provider) const plan = formatPlanLabel(p.planType) const reset = hasUsage ? soonestResetLabel(sections, now) : null + const tightest = mode === 'compact' ? getTightestUsageSection(p) : null return ( -
+
@@ -124,30 +153,29 @@ export function UsageRow({ ) : null} + ) : tightest ? ( + + + ) : reset ? ( {reset} ) : null}
- {hasUsage ? ( + {hasUsage && mode === 'verbose' ? (
- {sections.map((s) => { - const used = clampUsedPercent(s.window.usedPercent) - const shown = getDisplayedUsagePercentage(s.window.usedPercent, display) - return ( - - {shortLabel(p, s)} - - - - - {shown}% - - - ) - })} + {sections.map((section) => ( + + ))}
) : null}
@@ -268,6 +296,7 @@ export function UsageRosterPanel({ state={state} showSignInAction={showSignInAction} now={now} + mode={statusBarUsageMode} /> ) if (showSignInAction) { diff --git a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx index 6e69a482d95..3bab0e1bfcb 100644 --- a/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx +++ b/src/renderer/src/components/status-bar/provider-segment-monthly-window.test.tsx @@ -145,6 +145,21 @@ describe('ProviderSegment monthly window', () => { } }) + it('shows the footer bar only in verbose mode', async () => { + const { ProviderSegment } = await import('./StatusBar') + const limits = grokMonthlyLimits('ok') + + const verbose = renderToStaticMarkup( + + ) + const compact = renderToStaticMarkup( + + ) + + expect(verbose).toContain('data-usage-bar') + expect(compact).not.toContain('data-usage-bar') + }) + it('restores every inline window in verbose mode', async () => { const { ProviderSegment } = await import('./StatusBar') const limits: ProviderRateLimits = { From 35c76ba07a223dd823880b6eb9117689207052a6 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:11:04 -0700 Subject: [PATCH 010/123] Unify Linear and task provider setup into guided flows (#11533) * refactor(settings): unify Linear setup into guided flows across Task Sou Consolidate Linear API connection, agent skill installation, and Tasks visibility into guided step-by-step workflows. Moves Linear setup from a hidden integrations link into expandable cards within Task Sources (first-time path) and a prominent checklist in the Linear settings pane. Adds reusable TaskSourceProviderCard and step components so GitHub, GitLab, and Jira follow the same pattern. Surfaces incomplete provider warnings and auto-expands the first unfinished provider to reduce friction. * refactor(settings): unify Linear setup into guided flows across Task Sou Extract shared Linear skill setup logic into `useLinearAgentSkillSetup` hook so Task Sources and LinearAgentSkillPane both follow the same configuration path. Embed the skill install panel inside LinearAgentSkillGuide instead of placing it after, eliminating duplicate "Agent skill" section headers. Introduce task provider setup state helpers: distinguish fresh untouched providers from started-then-stalled ones (only warn on the latter), keep auto-expanded cards open across rechecks via sticky tracking, and handle unavailable/failed preflight status. Rename BrowserUseStepBadge to SetupStepBadge (used by multiple setup types now). Route connected credential management to Integrations cards instead of re-opening dialogs. Add settled flag to skill discovery so focus-triggered rescans don't flash UI on known results. * chore: credit task source guidance contribution Co-authored-by: Chen * fix: clear loading state regardless of generation's showLoading flag When a silent refresh supersedes an in-flight focus rescan, the losing rescan's finally block writes are dropped by the generation guard. Only the winning generation clears loading, so it must clear regardless of its own showLoading setting. Also move TasksPane auto-expanded tracking from effect to render phase to prevent layout-effect re-renders from seeing an unclaimed slot and collapsing the card. * fix: stop mutating refs during render for React Doctor Replace render-time ref writes in TasksPane sticky auto-expand and active project skill runtime identity caching with setState-during-render so static analysis can pass without changing behavior. --------- Co-authored-by: Chen --- .../MobileEmulatorAgentSetupGuideSteps.tsx | 2 +- .../mobile-emulator-agent-setup-cli-state.ts | 2 +- .../components/integration-status-pill.tsx | 2 +- .../settings/AgentSkillSetupPanel.test.tsx | 56 ++- .../settings/AgentSkillSetupPanel.tsx | 43 +- .../components/settings/BrowserUseCliStep.tsx | 2 +- .../settings/BrowserUseCookieImportStep.tsx | 2 +- .../settings/BrowserUseSkillStep.tsx | 2 +- .../settings/LinearAgentSkillGuide.test.tsx | 86 ++++ .../settings/LinearAgentSkillGuide.tsx | 191 +++++++++ .../settings/LinearAgentSkillNotes.test.tsx | 16 + .../settings/LinearAgentSkillNotes.tsx | 43 ++ .../settings/LinearAgentSkillPane.test.tsx | 118 +++++- .../settings/LinearAgentSkillPane.tsx | 190 ++++----- .../MobileEmulatorAgentControlRow.tsx | 2 +- .../src/components/settings/Settings.tsx | 6 +- ...serUseStepBadge.tsx => SetupStepBadge.tsx} | 0 .../settings/TaskSourceLinearSetup.test.tsx | 139 ++++++ .../settings/TaskSourceLinearSetup.tsx | 179 ++++++++ .../settings/TaskSourceProviderCard.test.tsx | 132 ++++++ .../settings/TaskSourceProviderCard.tsx | 174 ++++++++ .../TaskSourceShowInTasksStep.test.tsx | 41 ++ .../settings/TaskSourceShowInTasksStep.tsx | 99 +++++ .../settings/TaskSourceSimpleSetup.test.tsx | 43 ++ .../settings/TaskSourceSimpleSetup.tsx | 125 ++++++ .../components/settings/TaskSourceStepRow.tsx | 41 ++ .../components/settings/TasksPane.test.tsx | 394 ++++++++++++++++++ .../src/components/settings/TasksPane.tsx | 259 ++++++++---- ...nt-skill-installed-command-callers.test.ts | 24 +- .../settings/integration-card-shell.tsx | 3 +- .../settings/jira-integration-card.test.tsx | 1 + .../settings/jira-integration-card.tsx | 2 + .../linear-agent-skill-guide-content.ts | 62 +++ .../linear-agent-skill-install-cta.test.tsx | 4 +- .../linear-agent-skill-install-cta.tsx | 2 +- .../settings/linear-agent-skill-search.ts | 2 +- .../task-provider-integration-section-ids.ts | 2 + .../settings/task-source-setup-state.test.ts | 290 +++++++++++++ .../settings/task-source-setup-state.ts | 147 +++++++ .../task-tracker-integration-cards.test.tsx | 1 + .../task-tracker-integration-cards.tsx | 2 + .../src/components/settings/tasks-search.ts | 35 +- .../settings/use-linear-agent-skill-setup.ts | 106 +++++ ...se-task-source-provider-readiness.test.tsx | 172 ++++++++ .../use-task-source-provider-readiness.ts | 108 +++++ .../installed-agent-skills-change-event.ts | 1 + .../src/hooks/useActiveProjectSkillRuntime.ts | 24 +- .../useInstalledAgentSkills.react.test.tsx | 131 ++++++ .../src/hooks/useInstalledAgentSkills.test.ts | 28 +- .../src/hooks/useInstalledAgentSkills.ts | 33 +- .../hooks/useSettingsNavigationMetadata.ts | 6 +- src/renderer/src/i18n/locales/en.json | 134 ++++-- src/renderer/src/i18n/locales/es.json | 142 +++++-- src/renderer/src/i18n/locales/ja.json | 142 +++++-- src/renderer/src/i18n/locales/ko.json | 142 +++++-- src/renderer/src/i18n/locales/zh.json | 142 +++++-- src/renderer/src/lib/linear-usage-examples.ts | 6 +- 57 files changed, 3896 insertions(+), 387 deletions(-) create mode 100644 src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx create mode 100644 src/renderer/src/components/settings/LinearAgentSkillGuide.tsx create mode 100644 src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx create mode 100644 src/renderer/src/components/settings/LinearAgentSkillNotes.tsx rename src/renderer/src/components/settings/{BrowserUseStepBadge.tsx => SetupStepBadge.tsx} (100%) create mode 100644 src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceLinearSetup.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceProviderCard.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx create mode 100644 src/renderer/src/components/settings/TaskSourceStepRow.tsx create mode 100644 src/renderer/src/components/settings/TasksPane.test.tsx create mode 100644 src/renderer/src/components/settings/linear-agent-skill-guide-content.ts create mode 100644 src/renderer/src/components/settings/task-provider-integration-section-ids.ts create mode 100644 src/renderer/src/components/settings/task-source-setup-state.test.ts create mode 100644 src/renderer/src/components/settings/task-source-setup-state.ts create mode 100644 src/renderer/src/components/settings/use-linear-agent-skill-setup.ts create mode 100644 src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx create mode 100644 src/renderer/src/components/settings/use-task-source-provider-readiness.ts diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx index 33c5017b63b..f13bc19a336 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx @@ -9,7 +9,7 @@ import { } from '@/lib/agent-skill-cli-prerequisite' import { AgentSkillSetupPanel } from '../settings/AgentSkillSetupPanel' import { buildSkillCommandForRuntime } from '../settings/CliSkillRuntimeSetup' -import { StepBadge } from '../settings/BrowserUseStepBadge' +import { StepBadge } from '../settings/SetupStepBadge' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts index b20565d823f..4e350eaf0a4 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts @@ -1,5 +1,5 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' -import type { StepState } from '../settings/BrowserUseStepBadge' +import type { StepState } from '../settings/SetupStepBadge' export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean { return status?.state === 'installed' && status.pathConfigured === false diff --git a/src/renderer/src/components/integration-status-pill.tsx b/src/renderer/src/components/integration-status-pill.tsx index 418826cbe6b..a36faa2a926 100644 --- a/src/renderer/src/components/integration-status-pill.tsx +++ b/src/renderer/src/components/integration-status-pill.tsx @@ -4,7 +4,7 @@ export type IntegrationStatusTone = 'connected' | 'attention' | 'neutral' const TONE_CLASSES: Record = { connected: { - pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300', + pill: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' }, attention: { diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 69d2360b7e7..533144f8bdf 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => ({ clipboardWrite: vi.fn(), terminalProps: [] as { command: string; description: string }[], toastError: vi.fn(), - toastSuccess: vi.fn() + toastSuccess: vi.fn(), + skillsChanged: vi.fn(), + skillsRefreshed: vi.fn() })) vi.mock('sonner', () => ({ @@ -24,6 +26,11 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.skillsChanged, + notifyInstalledAgentSkillsRefreshed: mocks.skillsRefreshed +})) + vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => { mocks.terminalProps.push(props) @@ -39,6 +46,12 @@ vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ } })) +vi.mock('../skills/SkillFreshnessStatusPill', () => ({ + SkillFreshnessStatusPill: ({ skillName }: { skillName: string }) => ( + {skillName} + ) +})) + function panelProps( overrides: Partial> = {} ): ComponentProps { @@ -124,6 +137,8 @@ describe('AgentSkillSetupPanel', () => { mocks.terminalProps.length = 0 mocks.toastError.mockReset() mocks.toastSuccess.mockReset() + mocks.skillsChanged.mockReset() + mocks.skillsRefreshed.mockReset() Object.defineProperty(window, 'api', { configurable: true, value: { @@ -157,6 +172,26 @@ describe('AgentSkillSetupPanel', () => { expect(buttonLabels(html)).toContain('Re-check') }) + it('surfaces skill freshness under hideHeader for guided setup hubs', () => { + const html = renderPanel({ + installed: true, + hideHeader: true, + description: null, + freshnessSkillName: 'orca-linear' + }) + + expect(html).toContain('data-testid="skill-freshness"') + expect(html).toContain('orca-linear') + expect(html).not.toContain('CLI skill') + }) + + it('does not render an empty description paragraph when description is null', () => { + const html = renderPanel({ description: null, hideHeader: true }) + + expect(html).not.toContain('text-muted-foreground">null') + expect(html).not.toMatch(/

<\/p>/) + }) + it('hides only re-check when installed re-checks are disabled', () => { const html = renderPanel({ installed: true, showRecheckWhenInstalled: false }) @@ -217,6 +252,25 @@ describe('AgentSkillSetupPanel', () => { expect(buttonMarkupByLabel(html, 'Install')).toContain('disabled=""') }) + it('notifies sibling surfaces only after re-check finishes', async () => { + let finishRecheck: (() => void) | null = null + const recheck = new Promise((resolve) => { + finishRecheck = resolve + }) + await renderInteractivePanel({ onRecheck: () => recheck }) + + await clickButton('Re-check') + expect(mocks.skillsRefreshed).not.toHaveBeenCalled() + + await act(async () => { + finishRecheck?.() + await recheck + }) + + expect(mocks.skillsRefreshed).toHaveBeenCalledOnce() + expect(mocks.skillsChanged).not.toHaveBeenCalled() + }) + it('opens not-installed setup with the install command for preview, copy, and terminal', async () => { await renderInteractivePanel({ installedCommand: UPDATE_COMMAND }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 02862251d54..96bf0af5a8a 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -6,7 +6,10 @@ import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' -import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { + notifyInstalledAgentSkillsChanged, + notifyInstalledAgentSkillsRefreshed +} from '@/hooks/useInstalledAgentSkills' import { useMountedRef } from '@/hooks/useMountedRef' import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { cn } from '@/lib/utils' @@ -82,8 +85,8 @@ export function AgentSkillSetupPanel({ onBeforeOpenTerminal, showInstallWhenInstalled = true, showRecheckWhenInstalled = true, - installLabel = 'Install', - installedInstallLabel = 'Update', + installLabel, + installedInstallLabel, installVariant = 'outline', actionHint, openingHint, @@ -91,6 +94,12 @@ export function AgentSkillSetupPanel({ onRecheck, freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { + const resolvedInstallLabel = + installLabel ?? + translate('auto.components.settings.AgentSkillSetupPanel.installLabel', 'Install') + const resolvedInstalledInstallLabel = + installedInstallLabel ?? + translate('auto.components.settings.AgentSkillSetupPanel.updateLabel', 'Update') const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) const [terminalOpening, setTerminalOpening] = useState(false) @@ -211,8 +220,8 @@ export function AgentSkillSetupPanel({ {terminalOpening ? translate('auto.components.settings.AgentSkillSetupPanel.5f818f12ab', 'Preparing...') : installed - ? installedInstallLabel - : installLabel} + ? resolvedInstalledInstallLabel + : resolvedInstallLabel} ) : null} {!installed || showRecheckWhenInstalled ? ( @@ -222,10 +231,10 @@ export function AgentSkillSetupPanel({ size="sm" className="gap-1.5" onClick={() => { - void onRecheck() - if (freshnessSkillName) { - notifyInstalledAgentSkillsChanged() - } + void Promise.resolve(onRecheck()).then(() => { + // Reuse the completed scan so sibling surfaces sync without rediscovery. + notifyInstalledAgentSkillsRefreshed() + }) }} disabled={loading} > @@ -257,9 +266,15 @@ export function AgentSkillSetupPanel({ className={variant === 'card' ? cn('px-5 pt-5', terminalOpen ? 'pb-2' : 'pb-5') : 'pt-1.5'} > {hideHeader ? ( - error ? ( -

{error}

- ) : null + <> + {error ?

{error}

: null} + {/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */} + {installed && freshnessSkillName ? ( +
+ +
+ ) : null} + ) : (
{leading} @@ -303,7 +318,9 @@ export function AgentSkillSetupPanel({
)}
-

{description}

+ {description != null ? ( +

{description}

+ ) : null} {actionRow} {actionHint ?
{actionHint}
: null} {!installed && preInstallNotice && preInstallNoticeVisible ? ( diff --git a/src/renderer/src/components/settings/BrowserUseCliStep.tsx b/src/renderer/src/components/settings/BrowserUseCliStep.tsx index e3e2070ec2d..62474b3c2ae 100644 --- a/src/renderer/src/components/settings/BrowserUseCliStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCliStep.tsx @@ -2,7 +2,7 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index 3ac98241a12..1332883e4a1 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -16,7 +16,7 @@ import { } from '../ui/dropdown-menu' import { useAppStore } from '../../store' import { SearchableSetting } from './SearchableSetting' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx index 20788509441..a9a63cefa73 100644 --- a/src/renderer/src/components/settings/BrowserUseSkillStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseSkillStep.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { translate } from '@/i18n/i18n' type Props = { diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx new file mode 100644 index 00000000000..75da65cd885 --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.test.tsx @@ -0,0 +1,86 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { LinearAgentSkillGuide } from './LinearAgentSkillGuide' + +const baseStatus = { + connected: true, + connectionChecking: false, + skillInstalled: false, + skillChecking: false, + visibleInTasks: true +} + +describe('LinearAgentSkillGuide', () => { + it('renders the setup checklist with an inlined skill panel', () => { + const markup = renderToStaticMarkup( + Skill install panel
} + /> + ) + + expect(markup).toContain('Setup checklist') + expect(markup).toContain('2 of 3 ready') + expect(markup).toContain('Task Sources') + expect(markup).toContain('Skill install panel') + expect(markup).not.toContain('Ready below') + expect(markup).not.toContain('Install below') + expect(markup).not.toContain('Agent skill') + expect(markup).not.toContain('Open Task Sources setup') + }) + + it('marks the checklist complete when every step is done', () => { + const markup = renderToStaticMarkup( + Skill panel
} + /> + ) + + expect(markup).toContain('All set') + }) + + it('keeps durable progress while a skill recheck is in flight', () => { + const markup = renderToStaticMarkup( + Skill panel
} + /> + ) + + expect(markup).toContain('Checking…') + expect(markup).not.toContain('2 of 3 ready') + expect(markup).not.toContain('All set') + }) + + it('keeps durable progress while a connection check is in flight', () => { + const markup = renderToStaticMarkup( + Skill panel
} + /> + ) + + expect(markup).toContain('Checking…') + expect(markup).not.toContain('2 of 3 ready') + }) +}) diff --git a/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx new file mode 100644 index 00000000000..0bbcc77ec5f --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillGuide.tsx @@ -0,0 +1,191 @@ +import type { ReactNode } from 'react' +import { Check, Circle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { translate } from '@/i18n/i18n' + +export type LinearSetupStepStatus = { + connected: boolean + connectionChecking: boolean + skillInstalled: boolean + skillChecking: boolean + visibleInTasks: boolean +} + +type LinearAgentSkillGuideProps = { + status: LinearSetupStepStatus + onOpenTaskSources: () => void + onManageLinearAccess: () => void + // Why: skill install/update lives once under step 2 so the page does not + // repeat an "Agent skill" section after the checklist. + skillPanel: ReactNode +} + +function SetupStatusIcon({ + done, + checking +}: { + done: boolean + checking: boolean +}): React.JSX.Element { + // Keep a fixed size-5 slot so checking/done/pending never shift the column. + if (checking) { + return ( + + + + ) + } + if (done) { + return ( + + + + ) + } + return ( + + + + ) +} + +// Connect, skill, and Tasks visibility in one checklist — skill UI is inlined. +export function LinearAgentSkillGuide({ + status, + onOpenTaskSources, + onManageLinearAccess, + skillPanel +}: LinearAgentSkillGuideProps): React.JSX.Element { + // Count durable outcomes even while a recheck runs so the pill does not flash + // from "All set" down to "2 of 3 ready" during skill/connection scans. + const checking = status.connectionChecking || status.skillChecking + const completed = [status.connected, status.skillInstalled, status.visibleInTasks].filter( + Boolean + ).length + const total = 3 + const allReady = completed === total && !checking + + return ( +
+
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupTitle', + 'Setup checklist' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupBody', + 'All three are required for the full Tasks + agent loop. First-time path is also under Task Sources.' + )} +

+
+ + {checking + ? translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…') + : allReady + ? translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set') + : translate( + 'auto.components.settings.LinearAgentSkillGuide.setupProgress', + '{{done}} of {{total}} ready', + { done: completed, total } + )} + +
+ +
+
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupConnectTitle', + '1. Connect Linear' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupConnectBody', + 'Personal API key so Orca can list issues and open linked workspaces.' + )} +

+
+ +
+ +
+
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupSkillTitle', + '2. Install the agent skill' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupSkillBody', + 'Gives coding agents /orca-linear for reading, updates, triage, and attaching pull or merge requests.' + )} +

+
+
+ {skillPanel} +
+ +
+
+ +
+
+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupVisibleTitle', + '3. Show Linear in Tasks' + )} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.setupVisibleBody', + 'Keeps Linear in the Tasks source picker and sidebar shortcuts.' + )} +

+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx new file mode 100644 index 00000000000..c00e193df4a --- /dev/null +++ b/src/renderer/src/components/settings/LinearAgentSkillNotes.test.tsx @@ -0,0 +1,16 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { LinearAgentSkillNotes } from './LinearAgentSkillNotes' + +describe('LinearAgentSkillNotes', () => { + it('renders titled note cards instead of a dense bullet list', () => { + const markup = renderToStaticMarkup() + + expect(markup).toContain('Good to know') + expect(markup).toContain('Start from a Linear issue') + expect(markup).toContain('Mention /orca-linear') + expect(markup).toContain('Keys follow the runtime') + expect(markup).toContain('Hiding ≠ disconnect') + expect(markup).not.toContain(' +
+

+ {translate('auto.components.settings.LinearAgentSkillGuide.notesTitle', 'Good to know')} +

+

+ {translate( + 'auto.components.settings.LinearAgentSkillGuide.notesIntro', + 'Quick reminders once Linear is connected and the skill is installed.' + )} +

+
+ +
+ {notes.map((note) => { + const Icon = note.icon + return ( +
+
+ +
+
+

{note.title}

+

{note.body}

+
+
+ ) + })} +
+ + ) +} diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx index b7d6a9d56bc..e8e0dd442a0 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.test.tsx @@ -17,7 +17,11 @@ const mocks = vi.hoisted(() => ({ panelProps: [] as Record[], runtime: 'native' as 'native' | 'wsl', skillInstalled: true, - updateSkillName: 'orca-linear' + updateSkillName: 'orca-linear', + linearConnected: true, + visibleTaskProviders: ['github', 'linear'] as string[], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() })) vi.mock('./AgentSkillSetupPanel', () => ({ @@ -25,7 +29,8 @@ vi.mock('./AgentSkillSetupPanel', () => ({ mocks.panelProps.push(props) return (
-

{String(props.title)}

+ {!props.hideHeader &&

{String(props.title)}

} + {props.description != null &&

{String(props.description)}

} {props.installed ? 'Installed' : 'Not installed'} {String(props.command)} {String(props.installedCommand)} @@ -68,6 +73,41 @@ vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ }) })) +vi.mock('@/hooks/useLinearProviderConnected', () => ({ + useLinearProviderConnected: () => mocks.linearConnected +})) + +vi.mock('@/lib/provider-runtime-context', () => ({ + getProviderRuntimeContextKey: () => 'runtime-key' +})) + +vi.mock('@/components/linear-api-key-dialog', () => ({ + LinearApiKeyDialog: () => null +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + settings: { visibleTaskProviders: string[] } + linearStatusChecked: boolean + linearStatusContextKey: string + checkLinearConnection: () => void + settingsSearchQuery: string + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget, + settings: { visibleTaskProviders: mocks.visibleTaskProviders }, + linearStatusChecked: true, + linearStatusContextKey: 'runtime-key', + checkLinearConnection: vi.fn(), + settingsSearchQuery: '' + }) +})) + let root: Root | null = null let container: HTMLDivElement | null = null @@ -95,13 +135,71 @@ describe('LinearAgentSkillPane', () => { mocks.runtime = 'native' mocks.skillInstalled = true mocks.updateSkillName = 'orca-linear' + mocks.linearConnected = true + mocks.visibleTaskProviders = ['github', 'linear'] + mocks.openSettingsPage.mockClear() + mocks.openSettingsTarget.mockClear() }) - it('renders the Linear skill card and the usage examples', () => { + it('keeps a clickable route to the Integrations credentials pane', async () => { + const rendered = await renderPane() + const link = [...rendered.querySelectorAll('button')].find( + (button) => button.textContent === 'Integrations' + ) + + expect(link).toBeDefined() + await act(async () => { + link?.click() + }) + + expect(mocks.openSettingsPage).toHaveBeenCalled() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('routes the connected checklist credential action to Integrations', async () => { + const rendered = await renderPane() + const manageKeys = [...rendered.querySelectorAll('button')].find( + (button) => button.textContent === 'Manage keys' + ) + + expect(manageKeys).toBeDefined() + await act(async () => { + manageKeys?.click() + }) + + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('does not pass an icon the hideHeader layout would drop', () => { + renderToStaticMarkup() + + expect(mocks.panelProps.at(-1)).not.toHaveProperty('icon') + }) + + it('renders checklist with inlined skill panel and usage examples', () => { const markup = renderToStaticMarkup() - expect(markup).toContain('Linear skill') - expect(markup).toContain('How to use it') + expect(markup).toContain('Setup checklist') + expect(markup).toContain('2. Install the agent skill') + expect(mocks.panelProps.at(-1)).toEqual( + expect.objectContaining({ hideHeader: true, description: null }) + ) + expect(markup).not.toContain('Agent skill') + expect(markup).not.toContain('Ready below') + expect(markup).toContain('Example prompts') + expect(markup).toContain('Good to know') + expect(markup).toContain('Start from a Linear issue') + expect(markup).toContain('All set') + // Skill lives inside the checklist; notes stay after examples. + expect(markup.indexOf('Example prompts')).toBeLessThan(markup.indexOf('Good to know')) const examples = getLinearUsageExamples() expect(examples).toHaveLength(5) for (const example of examples) { @@ -111,6 +209,16 @@ describe('LinearAgentSkillPane', () => { } }) + it('shows incomplete checklist when the skill is missing', () => { + mocks.skillInstalled = false + const markup = renderToStaticMarkup() + + expect(markup).toContain('2 of 3 ready') + expect(mocks.panelProps.at(-1)).toEqual( + expect.objectContaining({ hideHeader: true, description: null }) + ) + }) + it('passes the orca-linear install/update commands and freshness on a local runtime', async () => { await renderPane() diff --git a/src/renderer/src/components/settings/LinearAgentSkillPane.tsx b/src/renderer/src/components/settings/LinearAgentSkillPane.tsx index 4f86bbb2653..8461d16cccd 100644 --- a/src/renderer/src/components/settings/LinearAgentSkillPane.tsx +++ b/src/renderer/src/components/settings/LinearAgentSkillPane.tsx @@ -1,37 +1,25 @@ -import { useMemo } from 'react' +import { useState } from 'react' import { ArrowRightCircle, BookOpen, Link2, ListTodo, MessageSquarePlus } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearIcon } from '@/components/icons/LinearIcon' import { Button } from '@/components/ui/button' import { useAppStore } from '@/store' -import { - LINEAR_AGENT_SKILL_NAMES, - ORCA_LINEAR_SKILL_INSTALL_COMMAND, - ORCA_LINEAR_SKILL_NAME -} from '@/lib/agent-feature-install-commands' -import { - AGENT_SKILL_CLI_PREREQUISITE_NOTICE, - ensureOrcaCliAvailableForAgentSkillTerminal -} from '@/lib/agent-skill-cli-prerequisite' -import { getLinearAgentSkillUpdateTarget } from '@/lib/linear-agent-skill-update-command' +import { ORCA_LINEAR_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { getLinearUsageExamples } from '@/lib/linear-usage-examples' import type { SkillUsageExample } from '@/lib/skill-usage-example' -import { - GLOBAL_AGENT_SKILL_SOURCE_KINDS, - useInstalledAgentSkillNames -} from '@/hooks/useInstalledAgentSkills' -import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' +import { normalizeVisibleTaskProviders } from '../../../../shared/task-providers' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' -import { - buildSkillCommandForRuntime, - ensureWslCliAvailableForAgentSkillTerminal, - getWslCliDistroRequest -} from './CliSkillRuntimeSetup' +import { LinearAgentSkillGuide } from './LinearAgentSkillGuide' +import { LinearAgentSkillNotes } from './LinearAgentSkillNotes' import { getLinearAgentSkillPaneSearchEntries } from './linear-agent-skill-search' import { SearchableSetting } from './SearchableSetting' import { SkillUsageExamplesSection } from './SkillUsageExamplesSection' +import { LINEAR_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' +import { useLinearAgentSkillSetup } from './use-linear-agent-skill-setup' import { translate } from '@/i18n/i18n' -export { getLinearAgentSkillPaneSearchEntries } from './linear-agent-skill-search' const LINEAR_EXAMPLE_ICONS: Record = { 'read-ticket': BookOpen, @@ -45,117 +33,109 @@ function resolveLinearExampleIcon(example: SkillUsageExample): LucideIcon { return LINEAR_EXAMPLE_ICONS[example.id] ?? LinearIcon } -// Why: this section is rendered as a Settings section only when the Linear -// provider is connected, so the orca-linear agent skill sits beside the -// connection that makes it useful. +// Checklist owns connect + skill + visibility; examples and notes sit below. export function LinearAgentSkillPane(): React.JSX.Element { - const activeSkillRuntime = useActiveProjectSkillRuntime() const openSettingsPage = useAppStore((state) => state.openSettingsPage) const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const settings = useAppStore((state) => state.settings) + const linearStatusChecked = useAppStore((state) => state.linearStatusChecked) + const linearStatusContextKey = useAppStore((state) => state.linearStatusContextKey) + const linearConnected = useLinearProviderConnected() + const checkLinearConnection = useAppStore((state) => state.checkLinearConnection) + const [linearKeyDialogOpen, setLinearKeyDialogOpen] = useState(false) + const skillSetup = useLinearAgentSkillSetup() + + const openTaskSources = (): void => { + openSettingsPage() + openSettingsTarget({ pane: 'tasks', repoId: null }) + } const openIntegrationSettings = (): void => { openSettingsPage() - openSettingsTarget({ pane: 'integrations', repoId: null }) + openSettingsTarget({ + pane: 'integrations', + repoId: null, + sectionId: LINEAR_INTEGRATION_SECTION_ID + }) } - const { - installed: linearSkillInstalled, - loading: linearSkillLoading, - error: linearSkillError, - skills: linearSkills, - refresh: refreshLinearSkill - } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { - discoveryTarget: activeSkillRuntime.discoveryTarget, - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) + const visibleInTasks = normalizeVisibleTaskProviders(settings?.visibleTaskProviders).includes( + 'linear' + ) + const connectionChecking = + linearStatusContextKey !== getProviderRuntimeContextKey(settings) || !linearStatusChecked - // Why: the built command also depends on the focused runtime environment, so - // memoizing it on the runtime alone can serve a stale Windows host command. - const installCommand = activeSkillRuntime.installDisabledReason - ? ORCA_LINEAR_SKILL_INSTALL_COMMAND - : buildSkillCommandForRuntime( - ORCA_LINEAR_SKILL_INSTALL_COMMAND, - activeSkillRuntime.agentRuntime - ) - const updateTarget = useMemo( - () => getLinearAgentSkillUpdateTarget(linearSkills, linearSkillInstalled), - [linearSkillInstalled, linearSkills] + const skillPanel = ( + ) - const updateCommand = activeSkillRuntime.installDisabledReason - ? updateTarget.command - : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) return ( - } - preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} - getPrerequisiteStatus={() => - activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? window.api.cli.getWslInstallStatus( - getWslCliDistroRequest(activeSkillRuntime.agentRuntime) - ) - : window.api.cli.getInstallStatus() - } - onBeforeOpenTerminal={async () => { - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) + setLinearKeyDialogOpen(true) } + skillPanel={skillPanel} /> + +

{translate( 'auto.components.settings.LinearAgentSkillPane.manageConnectionHint', @@ -170,10 +150,22 @@ export function LinearAgentSkillPane(): React.JSX.Element { > {translate( 'auto.components.settings.LinearAgentSkillPane.manageConnectionLink', - 'Integrations settings' + 'Integrations' )}

+ + { + void checkLinearConnection(true) + }} + />
) } diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index 1c8f7387a68..4aaad31fb2c 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -13,7 +13,7 @@ import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRunti import { useMobileEmulatorAgentSetupState } from '../emulator-pane/use-mobile-emulator-agent-setup-state' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup' -import { StepBadge } from './BrowserUseStepBadge' +import { StepBadge } from './SetupStepBadge' import { MobileEmulatorExamples } from './MobileEmulatorExamples' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0e93b0ad465..1cede19ac80 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1281,7 +1281,7 @@ function Settings(): React.JSX.Element { title={translate('auto.components.settings.Settings.linearTitle', 'Linear')} description={translate( 'auto.components.settings.Settings.linearDescription', - 'Give agents the skill to read and update your linked Linear tickets.' + 'How Linear works in Orca, setup checklist, agent skill, and example prompts.' )} searchEntries={getSectionSearchEntries('linear')} > @@ -1431,8 +1431,8 @@ function Settings(): React.JSX.Element { id="tasks" title={translate('auto.components.settings.Settings.11faa2f7dd', 'Task Sources')} description={translate( - 'auto.components.settings.Settings.dd72ed437a', - 'Choose which task providers appear in the Tasks page and sidebar.' + 'auto.components.settings.Settings.tasksDescription', + 'Connect providers, install the Linear skill, and choose what appears in Tasks.' )} searchEntries={getSectionSearchEntries('tasks')} > diff --git a/src/renderer/src/components/settings/BrowserUseStepBadge.tsx b/src/renderer/src/components/settings/SetupStepBadge.tsx similarity index 100% rename from src/renderer/src/components/settings/BrowserUseStepBadge.tsx rename to src/renderer/src/components/settings/SetupStepBadge.tsx diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx new file mode 100644 index 00000000000..878bef8125c --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TaskSourceLinearSetup } from './TaskSourceLinearSetup' + +const mocks = vi.hoisted(() => ({ + skillInstalled: false, + skillLoading: false, + checkLinearConnection: vi.fn(), + panelProps: [] as { installed?: boolean }[] +})) + +vi.mock('./AgentSkillSetupPanel', () => ({ + AgentSkillSetupPanel: (props: { installed?: boolean }) => { + mocks.panelProps.push(props) + return
{props.installed ? 'Installed' : 'Not installed'}
+ } +})) + +vi.mock('./CliSkillRuntimeSetup', () => ({ + buildSkillCommandForRuntime: (command: string) => command, + ensureWslCliAvailableForAgentSkillTerminal: vi.fn(), + getWslCliDistroRequest: () => undefined +})) + +vi.mock('@/lib/linear-agent-skill-update-command', () => ({ + getLinearAgentSkillUpdateTarget: () => ({ + command: 'npx skills update orca-linear --global', + skillName: 'orca-linear' + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], + useInstalledAgentSkillNames: () => ({ + installed: mocks.skillInstalled, + loading: mocks.skillLoading, + error: null, + skills: [], + refresh: vi.fn() + }) +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ + discoveryTarget: undefined, + agentRuntime: { runtime: 'native' }, + terminalShellOverride: undefined, + installDisabledReason: null + }) +})) + +vi.mock('@/components/linear-api-key-dialog', () => ({ + LinearApiKeyDialog: () => null +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { checkLinearConnection: () => void }) => unknown) => + selector({ checkLinearConnection: mocks.checkLinearConnection }) +})) + +function setupElement( + overrides: { + connected?: boolean + visible?: boolean + onOpenIntegrations?: () => void + } = {} +): React.JSX.Element { + return ( + + ) +} + +function renderSetup(overrides: { connected?: boolean; visible?: boolean } = {}): string { + return renderToStaticMarkup(setupElement(overrides)) +} + +describe('TaskSourceLinearSetup', () => { + beforeEach(() => { + mocks.skillInstalled = false + mocks.skillLoading = false + mocks.panelProps = [] + }) + + afterEach(() => { + cleanup() + }) + + it('blocks first-time skill install until Linear is connected', () => { + const markup = renderSetup({ connected: false }) + + expect(markup).toContain('Connect Linear first') + expect(markup).not.toContain('data-testid="skill-panel"') + }) + + it('shows the skill panel as done when the skill is already installed without a connection', () => { + mocks.skillInstalled = true + const markup = renderSetup({ connected: false }) + + expect(markup).not.toContain('Connect Linear first') + expect(markup).toContain('data-testid="skill-panel"') + expect(markup).toContain('Installed') + expect(mocks.panelProps.at(-1)).toEqual(expect.objectContaining({ installed: true })) + }) + + it('keeps the skill panel visible while a scan is in flight without a connection', () => { + mocks.skillLoading = true + const markup = renderSetup({ connected: false }) + + expect(markup).not.toContain('Connect Linear first') + expect(markup).toContain('data-testid="skill-panel"') + }) + + it('shows the install panel when connected and the skill is missing', () => { + const markup = renderSetup({ connected: true }) + + expect(markup).toContain('data-testid="skill-panel"') + expect(markup).toContain('Not installed') + expect(markup).not.toContain('Connect Linear first') + }) + + it('routes connected credential management to Integrations', () => { + const onOpenIntegrations = vi.fn() + const rendered = render(setupElement({ connected: true, onOpenIntegrations })) + + fireEvent.click(rendered.getByRole('button', { name: 'Manage keys' })) + + expect(onOpenIntegrations).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx b/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx new file mode 100644 index 00000000000..deb6ab2d72d --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceLinearSetup.tsx @@ -0,0 +1,179 @@ +import { useState } from 'react' +import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' +import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { useLinearAgentSkillSetup } from './use-linear-agent-skill-setup' +import { translate } from '@/i18n/i18n' + +type TaskSourceLinearSetupProps = { + connected: boolean + checking: boolean + visible: boolean + onToggleVisible: () => void + onOpenIntegrations: () => void + canHide: boolean +} + +// Keep connection, skill install, and visibility in the same guided flow. +export function TaskSourceLinearSetup({ + connected, + checking, + visible, + onToggleVisible, + onOpenIntegrations, + canHide +}: TaskSourceLinearSetupProps): React.JSX.Element { + const checkLinearConnection = useAppStore((s) => s.checkLinearConnection) + const [dialogOpen, setDialogOpen] = useState(false) + const skillSetup = useLinearAgentSkillSetup() + + const connectState = checking ? 'in-progress' : connected ? 'done' : 'pending' + // Skill install is independent of API connection; only gate the first-time install CTA. + const skillState = skillSetup.skillChecking + ? 'in-progress' + : skillSetup.skillInstalled + ? 'done' + : 'pending' + // Keep the panel visible while scanning so we don't flash "connect first" over an installed skill. + const skillInstallBlocked = !connected && !skillSetup.skillInstalled && !skillSetup.skillChecking + + return ( + <> +
    + setDialogOpen(true)} + > + {connected + ? translate( + 'auto.components.settings.TaskSourceLinearSetup.manageAccess', + 'Manage keys' + ) + : translate( + 'auto.components.settings.TaskSourceLinearSetup.addAccess', + 'Add Linear access' + )} + + } + > + {connected ? ( +

    + {translate( + 'auto.components.settings.TaskSourceLinearSetup.connectedHint', + 'Workspaces and keys are stored for the active runtime. You can add more access any time.' + )} +

    + ) : ( + + )} +
    + + + {skillInstallBlocked ? ( +

    + {translate( + 'auto.components.settings.TaskSourceLinearSetup.skillBlocked', + 'Connect Linear first, then install the skill for agents.' + )} +

    + ) : ( + + )} +
    + + +
+ + { + void checkLinearConnection(true) + }} + /> + + ) +} diff --git a/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx b/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx new file mode 100644 index 00000000000..71e0da02529 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceProviderCard.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TaskSourceProviderCard } from './TaskSourceProviderCard' + +const readiness = { + connected: false, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null +}) + +describe('TaskSourceProviderCard', () => { + it('shows the visibility action in only one place when expanded', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded + onToggleVisible={vi.fn()} + > + + + ) + + expect(markup.match(/aria-label="Hide Linear from Tasks"/g)).toHaveLength(1) + expect(markup).toContain('aria-label="Collapse Linear setup steps"') + expect(markup).toContain('aria-expanded="true"') + }) + + it('keeps visibility available while setup steps are collapsed', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded={false} + onToggleVisible={vi.fn()} + > + Expanded content + + ) + + expect(markup).toContain('aria-label="Hide Linear from Tasks"') + expect(markup).not.toContain('aria-pressed') + expect(markup).toContain('>Hide') + expect(markup).not.toContain('>Shown') + expect(markup).toContain('aria-label="Show Linear setup steps"') + expect(markup).not.toContain('Expanded content') + }) + + it('stays expanded when the auto-expand target moves to another provider', async () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + const card = (defaultExpanded: boolean): React.JSX.Element => ( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide + defaultExpanded={defaultExpanded} + onToggleVisible={vi.fn()} + > + Install terminal + + ) + + await act(async () => { + root?.render(card(true)) + }) + expect(container.textContent).toContain('Install terminal') + + // A slower provider check makes another card the auto-expand target. + await act(async () => { + root?.render(card(false)) + }) + + expect(container.textContent).toContain('Install terminal') + }) + + it('labels the locked final provider as shown instead of offering a hide action', () => { + const markup = renderToStaticMarkup( + } + name="Linear" + description="Linear setup" + readiness={readiness} + visible + canHide={false} + defaultExpanded={false} + onToggleVisible={vi.fn()} + /> + ) + + // aria-disabled, not disabled: the explanation must stay keyboard reachable. + expect(markup).toContain('aria-disabled="true"') + expect(markup).not.toContain('disabled=""') + expect(markup).toContain( + 'aria-label="Linear is shown in Tasks. At least one provider must stay visible."' + ) + expect(markup).toContain('>Shown') + expect(markup).not.toContain('>Hide') + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceProviderCard.tsx b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx new file mode 100644 index 00000000000..56e77fa5931 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceProviderCard.tsx @@ -0,0 +1,174 @@ +import { useId, useState, type ReactNode } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { getShowInTasksActionLabel, getShowInTasksLabel } from './TaskSourceShowInTasksStep' +import { + TASK_PROVIDER_SETUP_STATUS_TONE, + getTaskProviderCompletedSteps, + getTaskProviderSetupStatus, + isTaskProviderChecking, + type TaskProviderReadiness, + type TaskProviderSetupStatus +} from './task-source-setup-state' +import { translate } from '@/i18n/i18n' + +type TaskSourceProviderCardProps = { + icon: ReactNode + name: string + description: string + readiness: TaskProviderReadiness + visible: boolean + canHide: boolean + defaultExpanded: boolean + onToggleVisible: () => void + children?: ReactNode +} + +function getSetupStatusLabel(status: TaskProviderSetupStatus): string { + switch (status) { + case 'checking': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusChecking', + 'Checking…' + ) + case 'ready': + return translate('auto.components.settings.TaskSourceProviderCard.statusReady', 'Ready') + case 'connect-required': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusConnectRequired', + 'Connect required' + ) + case 'skill-required': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusSkillRequired', + 'Skill required' + ) + case 'unavailable': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusUnavailable', + 'Status unavailable' + ) + case 'hidden': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusHidden', + 'Hidden from Tasks' + ) + case 'incomplete': + return translate( + 'auto.components.settings.TaskSourceProviderCard.statusIncomplete', + 'Needs setup' + ) + } +} + +export function TaskSourceProviderCard({ + icon, + name, + description, + readiness, + visible, + canHide, + defaultExpanded, + onToggleVisible, + children +}: TaskSourceProviderCardProps): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded) + const [lastDefaultExpanded, setLastDefaultExpanded] = useState(defaultExpanded) + // Auto-expand only ever opens: collapsing is the user's call, so a readiness + // change that moves the auto-expand target cannot close a card in use. + if (lastDefaultExpanded !== defaultExpanded) { + setLastDefaultExpanded(defaultExpanded) + if (defaultExpanded) { + setExpanded(true) + } + } + const status = getTaskProviderSetupStatus(readiness) + const progress = getTaskProviderCompletedSteps(readiness) + const visibilityLocked = visible && !canHide + const setupId = useId() + + return ( +
+
+ + {icon} + + +
+
+

{name}

+ + {getSetupStatusLabel(status)} + + {isTaskProviderChecking(readiness) || + status === 'ready' || + status === 'unavailable' || + status === 'hidden' ? null : ( + // Only unfinished providers reach here, so the count is always partial. + + {`${progress.completed}/${progress.total}`} + + )} +
+

{description}

+
+ +
+ {!expanded ? ( + + ) : null} + +
+
+ + {expanded ? ( +
+ {children} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx new file mode 100644 index 00000000000..7fdd8e05229 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.test.tsx @@ -0,0 +1,41 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' + +describe('TaskSourceShowInTasksStep', () => { + it('uses an explicit hide action when another provider can remain visible', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('aria-pressed') + expect(markup).toContain('>Hide') + }) + + it('explains why the final visible provider cannot be hidden', () => { + const markup = renderToStaticMarkup( + + ) + + // aria-disabled, not disabled: the explanation must stay keyboard reachable. + expect(markup).toContain('aria-disabled="true"') + expect(markup).not.toContain('disabled=""') + expect(markup).toContain( + 'aria-label="GitLab is shown in Tasks. At least one provider must stay visible."' + ) + expect(markup).toContain('>Shown') + expect(markup).toContain('At least one provider must stay visible in Tasks.') + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx new file mode 100644 index 00000000000..a6b11f1ad13 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceShowInTasksStep.tsx @@ -0,0 +1,99 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { translate } from '@/i18n/i18n' + +type TaskSourceShowInTasksStepProps = { + index: number + providerLabel: string + visible: boolean + canHide: boolean + onToggleVisible: () => void + description?: string +} + +export function getShowInTasksLabel(visible: boolean, canHide: boolean): string { + if (!visible) { + return translate('auto.components.settings.TaskSourceShowInTasksStep.show', 'Show') + } + return canHide + ? translate('auto.components.settings.TaskSourceShowInTasksStep.hide', 'Hide') + : translate('auto.components.settings.TaskSourceShowInTasksStep.shown', 'Shown') +} + +export function getShowInTasksLastProviderHint(): string { + return translate( + 'auto.components.settings.TaskSourceShowInTasksStep.lastProviderHint', + 'At least one provider must stay visible in Tasks.' + ) +} + +export function getShowInTasksActionLabel( + visible: boolean, + canHide: boolean, + providerLabel: string +): string { + if (visible && !canHide) { + return translate( + 'auto.components.settings.TaskSourceShowInTasksStep.lastProviderAction', + '{{provider}} is shown in Tasks. At least one provider must stay visible.', + { provider: providerLabel } + ) + } + return visible + ? translate( + 'auto.components.settings.TaskSourceShowInTasksStep.hideProviderAction', + 'Hide {{provider}} from Tasks', + { provider: providerLabel } + ) + : translate( + 'auto.components.settings.TaskSourceShowInTasksStep.showProviderAction', + 'Show {{provider}} in Tasks', + { provider: providerLabel } + ) +} + +export function TaskSourceShowInTasksStep({ + index, + providerLabel, + visible, + canHide, + onToggleVisible, + description +}: TaskSourceShowInTasksStepProps): React.JSX.Element { + const locked = visible && !canHide + + return ( + + {getShowInTasksLabel(visible, canHide)} + + } + > + {locked ? ( +

{getShowInTasksLastProviderHint()}

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx b/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx new file mode 100644 index 00000000000..c85146cb6af --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceSimpleSetup.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JiraSetupSteps } from './TaskSourceSimpleSetup' + +const mocks = vi.hoisted(() => ({ + dialogOpen: [] as boolean[] +})) + +vi.mock('@/components/jira-connect-dialog', () => ({ + JiraConnectDialog: ({ open }: { open: boolean }) => { + mocks.dialogOpen.push(open) + return null + } +})) + +afterEach(() => { + cleanup() + mocks.dialogOpen = [] +}) + +describe('JiraSetupSteps', () => { + it('routes connected credential management to Integrations', () => { + const onOpenIntegrations = vi.fn() + const rendered = render( + + ) + + fireEvent.click(rendered.getByRole('button', { name: 'Manage keys' })) + + expect(onOpenIntegrations).toHaveBeenCalledOnce() + expect(mocks.dialogOpen.at(-1)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx b/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx new file mode 100644 index 00000000000..50f53cfa5f2 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceSimpleSetup.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { JiraConnectDialog } from '@/components/jira-connect-dialog' +import { Button } from '@/components/ui/button' +import { TaskSourceShowInTasksStep } from './TaskSourceShowInTasksStep' +import { TaskSourceStepRow } from './TaskSourceStepRow' +import { translate } from '@/i18n/i18n' + +type ConnectStepProps = { + visible: boolean + canHide: boolean + onToggleVisible: () => void + connected: boolean + checking: boolean +} + +function getConnectStepState(props: ConnectStepProps): 'in-progress' | 'done' | 'pending' { + if (props.checking) { + return 'in-progress' + } + return props.connected ? 'done' : 'pending' +} + +export function CodeHostSetupSteps( + props: ConnectStepProps & { + providerLabel: string + unavailable?: boolean + onOpenIntegrations: () => void + onRetryConnection: () => void + } +): React.JSX.Element { + const connectionDescription = props.unavailable + ? translate( + 'auto.components.settings.TasksPane.connectionCheckUnavailable', + "Orca couldn't check this connection. Try again, or open Integrations for setup details." + ) + : translate( + 'auto.components.settings.TasksPane.connectCodeHostDescription', + 'Install and authenticate the CLI under Integrations so Orca can load issues.' + ) + + return ( +
    + + {props.unavailable + ? translate('auto.components.settings.TasksPane.retryConnection', 'Try again') + : props.connected + ? translate('auto.components.settings.TasksPane.openIntegrations', 'Integrations') + : translate( + 'auto.components.settings.TasksPane.connectInIntegrations', + 'Set up in Integrations' + )} + + } + /> + +
+ ) +} + +export function JiraSetupSteps( + props: ConnectStepProps & { onConnected: () => void; onOpenIntegrations: () => void } +): React.JSX.Element { + const [dialogOpen, setDialogOpen] = useState(false) + + return ( + <> +
    + setDialogOpen(true)} + > + {props.connected + ? translate('auto.components.settings.TasksPane.manageJira', 'Manage keys') + : translate('auto.components.settings.TasksPane.addJira', 'Add Jira access')} + + } + /> + +
+ + + ) +} diff --git a/src/renderer/src/components/settings/TaskSourceStepRow.tsx b/src/renderer/src/components/settings/TaskSourceStepRow.tsx new file mode 100644 index 00000000000..3c9f9f6c799 --- /dev/null +++ b/src/renderer/src/components/settings/TaskSourceStepRow.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' +import { StepBadge, type StepState } from './SetupStepBadge' +import { cn } from '@/lib/utils' + +type TaskSourceStepRowProps = { + index: number + state: StepState + title: string + description: string + action?: ReactNode + children?: ReactNode + className?: string +} + +export function TaskSourceStepRow({ + index, + state, + title, + description, + action, + children, + className +}: TaskSourceStepRowProps): React.JSX.Element { + return ( +
  • +
    + +
    +
    +
    +

    {title}

    +

    {description}

    +
    + {action ?
    {action}
    : null} +
    + {children} +
    +
    +
  • + ) +} diff --git a/src/renderer/src/components/settings/TasksPane.test.tsx b/src/renderer/src/components/settings/TasksPane.test.tsx new file mode 100644 index 00000000000..e87df95c0db --- /dev/null +++ b/src/renderer/src/components/settings/TasksPane.test.tsx @@ -0,0 +1,394 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GlobalSettings, TaskProvider } from '../../../../shared/types' +import type { TaskProviderReadiness } from './task-source-setup-state' +import { TasksPane } from './TasksPane' + +const mocks = vi.hoisted(() => ({ + readiness: {} as Record, + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + refreshPreflightStatus: vi.fn(), + checkLinearConnection: vi.fn(), + checkJiraConnection: vi.fn(), + linearSetupProps: [] as { + connected: boolean + checking: boolean + onOpenIntegrations: () => void + }[], + jiraSetupProps: [] as { onOpenIntegrations: () => void }[] +})) + +vi.mock('./use-task-source-provider-readiness', () => ({ + useTaskSourceProviderReadiness: () => mocks.readiness +})) + +vi.mock('./use-integration-provider-status-refresh', () => ({ + useIntegrationProviderStatusRefresh: vi.fn() +})) + +vi.mock('./TaskSourceLinearSetup', () => ({ + TaskSourceLinearSetup: (props: { + connected: boolean + checking: boolean + onOpenIntegrations: () => void + }) => { + mocks.linearSetupProps.push(props) + return
    Linear setup steps
    + } +})) + +vi.mock('./TaskSourceSimpleSetup', () => ({ + CodeHostSetupSteps: (props: { + providerLabel: string + unavailable?: boolean + onRetryConnection: () => void + }) => ( +
    + {props.unavailable ? ( + <> + Orca couldn't check this connection + + + ) : ( + 'Code host setup' + )} +
    + ), + JiraSetupSteps: (props: { onOpenIntegrations: () => void }) => { + mocks.jiraSetupProps.push(props) + return
    Jira setup
    + } +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + refreshPreflightStatus: () => void + checkLinearConnection: () => void + checkJiraConnection: () => void + settingsSearchQuery: string + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget, + refreshPreflightStatus: mocks.refreshPreflightStatus, + checkLinearConnection: mocks.checkLinearConnection, + checkJiraConnection: mocks.checkJiraConnection, + settingsSearchQuery: '' + }) +})) + +const baseSettings = { + visibleTaskProviders: ['github', 'gitlab', 'linear'], + defaultTaskSource: 'github' +} as GlobalSettings + +const INCOMPLETE_BANNER = 'Some visible providers still need setup' +const LINEAR_SETUP_MARKER = 'data-testid="linear-setup"' + +function renderPane(): string { + return renderToStaticMarkup() +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderInteractivePane(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +async function rerenderInteractivePane(): Promise { + await act(async () => { + root?.render() + }) +} + +describe('TasksPane', () => { + beforeEach(() => { + mocks.linearSetupProps = [] + mocks.jiraSetupProps = [] + mocks.openSettingsPage.mockClear() + mocks.openSettingsTarget.mockClear() + mocks.readiness = { + github: { connected: true, checking: false, visible: true }, + gitlab: { connected: true, checking: false, visible: true }, + // Started-then-stalled: a Linear key is stored but agents have no skill. + linear: { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + }, + jira: { connected: false, checking: false, visible: false } + } + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + }) + + it('frames Task Sources as a guided setup hub, not visibility-only toggles', () => { + const markup = renderPane() + + expect(markup).toContain('Task management setup') + expect(markup).toContain('Linear also needs the agent skill') + expect(markup).toContain(INCOMPLETE_BANNER) + expect(markup).toContain('Linear setup steps') + expect(markup).toContain('Hide providers you do not use') + expect(markup).toContain('API access, the agent skill, and Show in Tasks') + }) + + it('does not warn on a fresh install where nothing is connected yet', () => { + // Settings ship with every provider visible, so untouched providers are the + // default state; the cards still say "Connect required" on their own. + mocks.readiness.github = { connected: false, checking: false, visible: true } + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + mocks.readiness.linear = { + connected: false, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + } + mocks.readiness.jira = { connected: false, checking: false, visible: true } + + const markup = renderPane() + + expect(markup).not.toContain(INCOMPLETE_BANNER) + expect(markup).toContain('Connect required') + }) + + it('hides the incomplete banner when every visible provider is ready', () => { + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('does not warn or expand while connection checks are still in flight', () => { + mocks.readiness.github = { connected: false, checking: true, visible: true } + mocks.readiness.gitlab = { connected: false, checking: true, visible: true } + mocks.readiness.linear = { + connected: false, + checking: true, + skillInstalled: false, + skillChecking: true, + visible: true + } + + const markup = renderPane() + + expect(markup).not.toContain(INCOMPLETE_BANNER) + expect(markup).not.toContain(LINEAR_SETUP_MARKER) + }) + + it('passes context-safe readiness into the expanded Linear setup', () => { + renderPane() + + expect(mocks.linearSetupProps).toEqual([ + expect.objectContaining({ connected: true, checking: false }) + ]) + }) + + it('deep-links connected Linear credential management to its integration card', async () => { + await renderInteractivePane() + + mocks.linearSetupProps.at(-1)?.onOpenIntegrations() + + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-linear' + }) + }) + + it('deep-links connected Jira credential management to its integration card', async () => { + mocks.readiness.jira = { connected: true, checking: false, visible: true } + await renderInteractivePane() + const expandJira = Array.from(container?.querySelectorAll('button') ?? []).find( + (button) => button.getAttribute('aria-label') === 'Show Jira setup steps' + ) + + await act(async () => { + expandJira?.click() + }) + mocks.jiraSetupProps.at(-1)?.onOpenIntegrations() + + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'integrations', + repoId: null, + sectionId: 'integrations-jira' + }) + }) + + it('auto-expands only the first incomplete provider', () => { + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + + const markup = renderPane() + + // GitLab is first in provider order, so Linear stays collapsed. + expect(markup).toContain('Code host setup') + expect(markup).not.toContain(LINEAR_SETUP_MARKER) + }) + + it('leaves hidden providers out of the incomplete warning', () => { + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: false + } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('keeps Linear setup mounted while a skill recheck is in flight after auto-expand', async () => { + // Same component instance keeps the sticky auto-expand ref across rechecks. + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + expect(container?.textContent).toContain(INCOMPLETE_BANNER) + + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: true, + visible: true + } + await rerenderInteractivePane() + + // Sticky expand keeps the (possibly open) install terminal mounted mid-scan. + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + }) + + it('keeps the auto-expanded card open after hiding it instead of popping another open', async () => { + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + + // Hiding the stalled provider is the banner's own advice; GitHub must not + // silently take over the expansion on a later unrelated render. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: false + } + mocks.readiness.github = { connected: false, checking: false, visible: true } + await rerenderInteractivePane() + await rerenderInteractivePane() + + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + expect(container?.textContent).not.toContain('Code host setup') + }) + + it('keeps Linear expanded when the slower code-host preflight lands after it', async () => { + // Cold open: nothing has resolved, so no card auto-expands yet. + mocks.readiness.github = { connected: false, checking: true, visible: true } + mocks.readiness.gitlab = { connected: false, checking: true, visible: true } + mocks.readiness.linear = { + connected: false, + checking: true, + skillInstalled: false, + skillChecking: true, + visible: true + } + await renderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).toBeNull() + + // Linear status + skill scan land first, so Linear auto-expands. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: false, + skillChecking: false, + visible: true + } + await rerenderInteractivePane() + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + + // gh/glab preflight lands last with gh unauthenticated: GitHub is now the + // first incomplete provider, but it must not steal Linear's expansion. + mocks.readiness.github = { connected: false, checking: false, visible: true } + mocks.readiness.gitlab = { connected: false, checking: false, visible: true } + await rerenderInteractivePane() + + expect(container?.querySelector('[data-testid="linear-setup"]')).not.toBeNull() + }) + + it('does not warn about an unconnected code host once Linear is finished', () => { + // A code host is single-step, so "not connected" is never a stalled setup. + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + mocks.readiness.github = { connected: false, checking: false, visible: true } + + expect(renderPane()).not.toContain(INCOMPLETE_BANNER) + }) + + it('shows a retry action instead of setup instructions when preflight is unavailable', async () => { + mocks.readiness.github = { + connected: false, + checking: false, + unavailable: true, + visible: true + } + mocks.readiness.gitlab = { connected: true, checking: false, visible: true } + mocks.readiness.linear = { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + } + await renderInteractivePane() + + expect(container?.textContent).toContain('Status unavailable') + expect(container?.textContent).toContain("Orca couldn't check this connection") + const retry = Array.from(container?.querySelectorAll('button') ?? []).find( + (button) => button.textContent === 'Try again' + ) + expect(retry).toBeDefined() + + await act(async () => { + retry?.click() + }) + expect(mocks.refreshPreflightStatus).toHaveBeenCalledWith({ force: true }) + }) +}) diff --git a/src/renderer/src/components/settings/TasksPane.tsx b/src/renderer/src/components/settings/TasksPane.tsx index c95383a013b..c5b547b3850 100644 --- a/src/renderer/src/components/settings/TasksPane.tsx +++ b/src/renderer/src/components/settings/TasksPane.tsx @@ -1,16 +1,31 @@ -import { Check, Github, Gitlab } from 'lucide-react' +import { useState } from 'react' +import { Github, Gitlab } from 'lucide-react' import type { GlobalSettings, TaskProvider } from '../../../../shared/types' import { TASK_PROVIDERS, normalizeVisibleTaskProviders, resolveVisibleTaskProvider } from '../../../../shared/task-providers' -import { cn } from '@/lib/utils' import { JiraIcon } from '@/components/icons/JiraIcon' import { LinearIcon } from '@/components/icons/LinearIcon' -import { Label } from '../ui/label' +import { Button } from '@/components/ui/button' +import { useAppStore } from '@/store' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' +import { CodeHostSetupSteps, JiraSetupSteps } from './TaskSourceSimpleSetup' +import { TaskSourceLinearSetup } from './TaskSourceLinearSetup' +import { TaskSourceProviderCard } from './TaskSourceProviderCard' +import { + getStalledVisibleTaskProviders, + resolveStickyAutoExpandedTaskProvider +} from './task-source-setup-state' +import { + JIRA_INTEGRATION_SECTION_ID, + LINEAR_INTEGRATION_SECTION_ID +} from './task-provider-integration-section-ids' +import { getTasksPaneSearchKeywords } from './tasks-search' +import { useIntegrationProviderStatusRefresh } from './use-integration-provider-status-refresh' +import { useTaskSourceProviderReadiness } from './use-task-source-provider-readiness' import { translate } from '@/i18n/i18n' type TasksPaneProps = { @@ -18,68 +33,88 @@ type TasksPaneProps = { updateSettings: (updates: Partial) => void } -const TASK_PROVIDER_OPTIONS: readonly { - id: TaskProvider - label: string - description: string - Icon: (props: { className?: string }) => React.JSX.Element -}[] = [ +const PROVIDER_META: Record< + TaskProvider, { - id: 'github', + label: string + description: string + Icon: (props: { className?: string }) => React.JSX.Element + } +> = { + github: { get label() { return translate('auto.components.settings.TasksPane.e14063e727', 'GitHub') }, get description() { return translate( - 'auto.components.settings.TasksPane.1db47236cd', - 'Show GitHub in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.githubDescription', + 'Browse GitHub issues and start workspaces from them.' ) }, Icon: ({ className }) => }, - { - id: 'gitlab', + gitlab: { get label() { return translate('auto.components.settings.TasksPane.7c5d7fdc20', 'GitLab') }, get description() { return translate( - 'auto.components.settings.TasksPane.dd67a1b6e1', - 'Show GitLab in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.gitlabDescription', + 'Browse GitLab issues and start workspaces from them.' ) }, Icon: ({ className }) => }, - { - id: 'linear', + linear: { get label() { return translate('auto.components.settings.TasksPane.09ae2d7c51', 'Linear') }, get description() { return translate( - 'auto.components.settings.TasksPane.e4170c9615', - 'Show Linear in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.linearDescription', + 'Connect Linear, install the agent skill, and show it in Tasks.' ) }, Icon: ({ className }) => }, - { - id: 'jira', + jira: { get label() { return translate('auto.components.settings.TasksPane.6b23a34f6d', 'Jira') }, get description() { return translate( - 'auto.components.settings.TasksPane.8e1305fcc6', - 'Show Jira in the Tasks source picker and sidebar shortcuts.' + 'auto.components.settings.TasksPane.jiraDescription', + 'Connect Jira Cloud or self-hosted Jira and show it in Tasks.' ) }, Icon: ({ className }) => } -] +} export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.JSX.Element { const visibleProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const checkJiraConnection = useAppStore((s) => s.checkJiraConnection) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const readinessByProvider = useTaskSourceProviderReadiness(visibleProviders) + useIntegrationProviderStatusRefresh() + + // Warn only about started-then-stalled setup; untouched providers are the default. + const stalledVisible = getStalledVisibleTaskProviders(TASK_PROVIDERS, readinessByProvider) + // Sticky across rechecks so expanded Linear install terminals are not unmounted. + // Claim during render (not an effect): a layout effect elsewhere can force a + // sync re-render before passive effects flush and collapse the open card. + // useState (not a ref write) keeps render pure for React Doctor. + const [previousAutoExpanded, setPreviousAutoExpanded] = useState(null) + const autoExpandedProvider = resolveStickyAutoExpandedTaskProvider({ + providers: TASK_PROVIDERS, + readinessByProvider, + previousAutoExpanded + }) + if (autoExpandedProvider !== null && previousAutoExpanded === null) { + setPreviousAutoExpanded(autoExpandedProvider) + } const toggleProvider = (provider: TaskProvider): void => { const isVisible = visibleProviders.includes(provider) @@ -97,87 +132,137 @@ export function TasksPane({ settings, updateSettings }: TasksPaneProps): React.J }) } + const openIntegrations = (sectionId?: string): void => { + openSettingsPage() + openSettingsTarget({ + pane: 'integrations', + repoId: null, + ...(sectionId ? { sectionId } : {}) + }) + } + return (
    + {stalledVisible.length > 0 ? ( +
    +

    + {translate( + 'auto.components.settings.TasksPane.incompleteBannerTitle', + 'Some visible providers still need setup' + )} +

    + {/* Linear is the only multi-step provider today; the generic body + covers any future provider that can stall the same way. */} +

    + {stalledVisible.includes('linear') + ? translate( + 'auto.components.settings.TasksPane.incompleteBannerBodyWithLinear', + 'Hide providers you do not use, or expand a card and finish its steps. For Linear: API access, the agent skill, and Show in Tasks.' + ) + : translate( + 'auto.components.settings.TasksPane.incompleteBannerBody', + 'Hide providers you do not use, or expand a card and finish its steps.' + )} +

    +
    + ) : null} + - {TASK_PROVIDER_OPTIONS.map((option) => { - const enabled = visibleProviders.includes(option.id) - const isLastEnabled = enabled && visibleProviders.length === 1 - const Icon = option.Icon + {TASK_PROVIDERS.map((provider) => { + const meta = PROVIDER_META[provider] + const readiness = readinessByProvider[provider] + const Icon = meta.Icon + const visible = readiness.visible + const canHide = visibleProviders.length > 1 return ( - + {provider === 'linear' ? ( + toggleProvider('linear')} + onOpenIntegrations={() => openIntegrations(LINEAR_INTEGRATION_SECTION_ID)} + /> + ) : provider === 'jira' ? ( + toggleProvider('jira')} + onConnected={() => void checkJiraConnection()} + onOpenIntegrations={() => openIntegrations(JIRA_INTEGRATION_SECTION_ID)} + /> + ) : ( + toggleProvider(provider)} + onOpenIntegrations={() => openIntegrations()} + onRetryConnection={() => void refreshPreflightStatus({ force: true })} + /> + )} + ) })} + +

    + {translate( + 'auto.components.settings.TasksPane.integrationsHint', + 'Credentials for all providers also live under' + )}{' '} + + {translate( + 'auto.components.settings.TasksPane.skillHint', + '. After Linear is connected, usage examples stay under Settings → Linear.' + )} +

    ) diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 78f6df37490..9f041ea076f 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -24,10 +24,24 @@ const updateCapableCallers = new Map([ ['COMPUTER_USE_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}'] ], [ - // Why: the Linear settings section shares the update-target resolver so - // legacy-only installs keep the command and freshness identity aligned. + // Shared hook owns update-target resolution for Linear settings + Task Sources. + 'src/renderer/src/components/settings/use-linear-agent-skill-setup.ts', + [ + 'getLinearAgentSkillUpdateTarget', + 'updateTarget.command', + // The builder also reads the focused runtime environment, so memoizing + // either command on the runtime alone serves a stale Windows host command. + 'const installCommand = activeSkillRuntime.installDisabledReason', + 'const updateCommand = activeSkillRuntime.installDisabledReason' + ] + ], + [ 'src/renderer/src/components/settings/LinearAgentSkillPane.tsx', - ['getLinearAgentSkillUpdateTarget', 'installedCommand={updateCommand}'] + ['installedCommand={skillSetup.updateCommand}'] + ], + [ + 'src/renderer/src/components/settings/TaskSourceLinearSetup.tsx', + ['installedCommand={skillSetup.updateCommand}'] ], [ 'src/renderer/src/components/settings/EphemeralVmsPane.tsx', @@ -94,10 +108,12 @@ const installOnlyCallers = new Map([ const directPanelCallers = new Set([ // BrowserUsePane and LinearAgentSkillSetupPrompt delegate through child setup // components that forward installedCommand and are validated separately above. + // use-linear-agent-skill-setup owns the update-target resolver but is not a panel host. ...[...updateCapableCallers.keys()].filter( (relativePath) => relativePath !== 'src/renderer/src/components/settings/BrowserUsePane.tsx' && - relativePath !== 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx' + relativePath !== 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx' && + relativePath !== 'src/renderer/src/components/settings/use-linear-agent-skill-setup.ts' ), ...installOnlyCallers.keys() ]) diff --git a/src/renderer/src/components/settings/integration-card-shell.tsx b/src/renderer/src/components/settings/integration-card-shell.tsx index 54c288d4258..0e5f80d6111 100644 --- a/src/renderer/src/components/settings/integration-card-shell.tsx +++ b/src/renderer/src/components/settings/integration-card-shell.tsx @@ -18,6 +18,7 @@ export function IntegrationCardShell(props: { statusTone: IntegrationCardStatusTone checking?: boolean className?: string + settingsSectionId?: string actions?: React.ReactNode children?: React.ReactNode }): React.JSX.Element { @@ -36,7 +37,7 @@ export function IntegrationCardShell(props: { ) return ( -
    +
    {props.icon}
    diff --git a/src/renderer/src/components/settings/jira-integration-card.test.tsx b/src/renderer/src/components/settings/jira-integration-card.test.tsx index 50b3990748c..0abbeb706ef 100644 --- a/src/renderer/src/components/settings/jira-integration-card.test.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.test.tsx @@ -99,6 +99,7 @@ describe('JiraIntegrationCard account scope', () => { const rendered = await renderCard() + expect(rendered.querySelector('[data-settings-section="integrations-jira"]')).not.toBeNull() expect(rendered.textContent).toContain('Account scope: Remote server: runtime-1') expect(rendered.textContent).toContain('Acme Jira') expect(rendered.textContent).toContain('https://acme.atlassian.net · jira@example.test') diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 8427e6f4cf7..e1889c2f073 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -13,6 +13,7 @@ import { IntegrationCardDetails, IntegrationCardShell } from './integration-card import { useIntegrationSubordinateRowClass } from './integration-card-presentation' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' +import { JIRA_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -76,6 +77,7 @@ export function JiraIntegrationCard(): React.JSX.Element { return ( } name="Jira" description={ diff --git a/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts b/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts new file mode 100644 index 00000000000..63fbb4f75be --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-guide-content.ts @@ -0,0 +1,62 @@ +import { GitBranch, KeyRound, EyeOff, Slash, type LucideIcon } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export type LinearGuideNote = { + id: string + icon: LucideIcon + title: string + body: string +} + +export function getLinearGuideNotes(): LinearGuideNote[] { + return [ + { + id: 'linked-worktree', + icon: GitBranch, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteLinkedTitle', + 'Start from a Linear issue' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteLinkedBody', + 'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.' + ) + }, + { + id: 'slash-command', + icon: Slash, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteSlashTitle', + 'Mention /orca-linear' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteSlashBody', + 'In chat, use /orca-linear (or ask in plain language) so the agent loads the skill for that turn.' + ) + }, + { + id: 'keys', + icon: KeyRound, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteKeysTitle', + 'Keys follow the runtime' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteKeysBody', + 'API keys and workspaces are stored for the active runtime.' + ) + }, + { + id: 'visibility', + icon: EyeOff, + title: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteVisibilityTitle', + 'Hiding ≠ disconnect' + ), + body: translate( + 'auto.components.settings.LinearAgentSkillGuide.noteVisibilityBody', + 'Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.' + ) + } + ] +} diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx index a3a4a1db3dd..793e9985d88 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx @@ -113,7 +113,9 @@ describe('LinearAgentSkillInstallCta', () => { expect(rendered.textContent).toContain('Agent skill:') expect(rendered.textContent).toContain('orca-linear') expect(rendered.textContent).toContain('Not installed') - expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.') + expect(rendered.textContent).toContain( + 'Full guided setup (connect + skill + visibility) is under Settings → Task Sources.' + ) expect(rendered.textContent).toContain( 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' ) diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx index 43e755dcbe2..cec5c4013ac 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -143,7 +143,7 @@ export function LinearAgentSkillInstallCta({ ) : translate( 'auto.components.settings.linear.agent.skill.install.cta.description', - 'Let your agents read and edit Linear tasks.' + 'Let agents read and edit Linear tasks. Full guided setup (connect + skill + visibility) is under Settings → Task Sources.' )}

    diff --git a/src/renderer/src/components/settings/linear-agent-skill-search.ts b/src/renderer/src/components/settings/linear-agent-skill-search.ts index 3fdf6dbe2b9..1eeda947034 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-search.ts +++ b/src/renderer/src/components/settings/linear-agent-skill-search.ts @@ -7,7 +7,7 @@ export const getLinearAgentSkillPaneSearchEntries = createLocalizedCatalog(() => title: translate('auto.components.settings.linear.agent.skill.search.title', 'Linear'), description: translate( 'auto.components.settings.linear.agent.skill.search.description', - 'Give agents the skill to read and update your linked Linear tickets.' + 'Linear skill status, usage examples, and links to Task Sources setup.' ), keywords: [ ...translateSearchKeyword( diff --git a/src/renderer/src/components/settings/task-provider-integration-section-ids.ts b/src/renderer/src/components/settings/task-provider-integration-section-ids.ts new file mode 100644 index 00000000000..6cd7d9933af --- /dev/null +++ b/src/renderer/src/components/settings/task-provider-integration-section-ids.ts @@ -0,0 +1,2 @@ +export const LINEAR_INTEGRATION_SECTION_ID = 'integrations-linear' +export const JIRA_INTEGRATION_SECTION_ID = 'integrations-jira' diff --git a/src/renderer/src/components/settings/task-source-setup-state.test.ts b/src/renderer/src/components/settings/task-source-setup-state.test.ts new file mode 100644 index 00000000000..d2e4e6a5c27 --- /dev/null +++ b/src/renderer/src/components/settings/task-source-setup-state.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import type { TaskProvider } from '../../../../shared/types' +import { + getAutoExpandedTaskProvider, + getIncompleteVisibleTaskProviders, + getStalledVisibleTaskProviders, + getTaskProviderCompletedSteps, + getTaskProviderSetupStatus, + isTaskProviderReady, + resolveStickyAutoExpandedTaskProvider, + type TaskProviderReadiness +} from './task-source-setup-state' + +const ORDER: readonly TaskProvider[] = ['github', 'gitlab', 'linear', 'jira'] + +function buildReadiness( + overrides: Partial>> = {} +): Record { + const base: Record = { + github: { connected: true, checking: false, visible: true }, + gitlab: { connected: true, checking: false, visible: true }, + linear: { + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false, + visible: true + }, + jira: { connected: true, checking: false, visible: true } + } + for (const provider of ORDER) { + Object.assign(base[provider], overrides[provider]) + } + return base +} + +describe('task-source-setup-state', () => { + it('counts Linear readiness as three steps', () => { + expect( + getTaskProviderCompletedSteps({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toEqual({ completed: 2, total: 3 }) + }) + + it('counts code-host readiness as two steps', () => { + expect( + getTaskProviderCompletedSteps({ connected: true, checking: false, visible: true }) + ).toEqual({ completed: 2, total: 2 }) + }) + + it('marks Linear ready only when connected, skill installed, and visible', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: true, + visible: true + }) + ).toBe(true) + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toBe(false) + }) + + it('never reports ready while a check is in flight', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + skillInstalled: true, + skillChecking: true, + visible: true + }) + ).toBe(false) + expect(isTaskProviderReady({ connected: true, checking: true, visible: true })).toBe(false) + }) + + it('reports the first unmet step as the status', () => { + expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: true })).toBe( + 'checking' + ) + expect(getTaskProviderSetupStatus({ connected: false, checking: false, visible: true })).toBe( + 'connect-required' + ) + expect( + getTaskProviderSetupStatus({ + connected: false, + checking: false, + unavailable: true, + visible: true + }) + ).toBe('unavailable') + expect( + getTaskProviderSetupStatus({ + connected: true, + checking: false, + skillInstalled: false, + visible: true + }) + ).toBe('skill-required') + expect(getTaskProviderSetupStatus({ connected: true, checking: false, visible: false })).toBe( + 'hidden' + ) + expect(getTaskProviderSetupStatus({ connected: true, checking: false, visible: true })).toBe( + 'ready' + ) + }) + + it('never reports an unavailable provider as ready', () => { + expect( + isTaskProviderReady({ + connected: true, + checking: false, + unavailable: true, + visible: true + }) + ).toBe(false) + }) + + it('treats hidden providers as deliberately disabled regardless of connection state', () => { + expect(getTaskProviderSetupStatus({ connected: false, checking: false, visible: false })).toBe( + 'hidden' + ) + expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: false })).toBe( + 'hidden' + ) + }) + + it('excludes hidden and still-checking providers from the incomplete list', () => { + const readiness = buildReadiness({ + github: { connected: false, visible: false }, + gitlab: { connected: false, checking: true }, + linear: { skillInstalled: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, readiness)).toEqual(['linear']) + }) + + it('auto-expands only the first incomplete visible provider', () => { + const readiness = buildReadiness({ + gitlab: { connected: false }, + linear: { skillInstalled: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, readiness)).toEqual(['gitlab', 'linear']) + expect(getAutoExpandedTaskProvider(ORDER, readiness)).toBe('gitlab') + }) + + it('auto-expands nothing once every visible provider is ready', () => { + expect(getAutoExpandedTaskProvider(ORDER, buildReadiness())).toBeNull() + }) + + it('does not warn about providers nobody has connected yet', () => { + // Settings ship with every provider visible, so an untouched provider is the + // default state rather than something to flag on a fresh install. + const untouched = buildReadiness({ + github: { connected: false }, + gitlab: { connected: false }, + linear: { connected: false, skillInstalled: false }, + jira: { connected: false } + }) + + expect(getIncompleteVisibleTaskProviders(ORDER, untouched)).toEqual([ + 'github', + 'gitlab', + 'linear', + 'jira' + ]) + expect(getStalledVisibleTaskProviders(ORDER, untouched)).toEqual([]) + }) + + it('warns about setup that started and stalled partway', () => { + const stalled = buildReadiness({ + github: { connected: false }, + linear: { skillInstalled: false } + }) + + // GitHub was never connected; Linear has a key but no skill. + expect(getStalledVisibleTaskProviders(ORDER, stalled)).toEqual(['linear']) + }) + + it('counts an installed skill as started even without an API key', () => { + const stalled = buildReadiness({ + linear: { connected: false, skillInstalled: true } + }) + + expect(getStalledVisibleTaskProviders(ORDER, stalled)).toEqual(['linear']) + }) + + it('keeps still-checking and hidden providers out of the warning', () => { + const readiness = buildReadiness({ + gitlab: { connected: true, checking: true, visible: true }, + linear: { skillInstalled: false, visible: false } + }) + + expect(getStalledVisibleTaskProviders(ORDER, readiness)).toEqual([]) + }) + + it('keeps the previous auto-expanded provider open while a recheck is in flight', () => { + const whileChecking = buildReadiness({ + linear: { skillInstalled: false, skillChecking: true } + }) + + // Fresh incomplete list excludes checking providers (banner path). + expect(getAutoExpandedTaskProvider(ORDER, whileChecking)).toBeNull() + // Sticky path keeps Linear open so install UI is not unmounted mid-scan. + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: whileChecking, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('does not switch to a later incomplete provider during the previous provider recheck', () => { + const whileChecking = buildReadiness({ + linear: { skillInstalled: false, skillChecking: true }, + jira: { connected: false } + }) + + expect(getAutoExpandedTaskProvider(ORDER, whileChecking)).toBe('jira') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: whileChecking, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('does not hand the expansion to a provider whose check landed later', () => { + // gh/glab preflight is slower than the Linear status + skill scan, so GitHub + // only becomes eligible after Linear already auto-expanded. + const afterPreflight = buildReadiness({ + github: { connected: false }, + gitlab: { connected: false }, + linear: { skillInstalled: false } + }) + + expect(getAutoExpandedTaskProvider(ORDER, afterPreflight)).toBe('github') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterPreflight, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('keeps the auto-expand slot after the chosen provider finishes', () => { + // Releasing it would let the next render pop a still-incomplete card open. + const afterLinearCompletes = buildReadiness({ github: { connected: false } }) + + expect(getAutoExpandedTaskProvider(ORDER, afterLinearCompletes)).toBe('github') + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterLinearCompletes, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) + + it('keeps the auto-expand slot when the chosen provider is hidden', () => { + // Hiding an unfinished provider is the banner's own advice, so it must not + // hand the expansion to another card. + const afterHidingLinear = buildReadiness({ + github: { connected: false }, + linear: { skillInstalled: false, visible: false } + }) + + expect( + resolveStickyAutoExpandedTaskProvider({ + providers: ORDER, + readinessByProvider: afterHidingLinear, + previousAutoExpanded: 'linear' + }) + ).toBe('linear') + }) +}) diff --git a/src/renderer/src/components/settings/task-source-setup-state.ts b/src/renderer/src/components/settings/task-source-setup-state.ts new file mode 100644 index 00000000000..3ad27d48fa9 --- /dev/null +++ b/src/renderer/src/components/settings/task-source-setup-state.ts @@ -0,0 +1,147 @@ +import type { TaskProvider } from '../../../../shared/types' +import type { IntegrationStatusTone } from '@/components/integration-status-pill' + +export type TaskProviderReadiness = { + connected: boolean + checking: boolean + unavailable?: boolean + /** Linear only — agent skill install. Other providers leave this undefined. */ + skillInstalled?: boolean + skillChecking?: boolean + visible: boolean +} + +export type TaskProviderSetupStatus = + | 'checking' + | 'ready' + | 'connect-required' + | 'skill-required' + | 'unavailable' + | 'hidden' + | 'incomplete' + +export const TASK_PROVIDER_SETUP_STATUS_TONE: Record< + TaskProviderSetupStatus, + IntegrationStatusTone +> = { + checking: 'neutral', + ready: 'connected', + // Why: hiding a provider is a deliberate choice, not something to warn about. + hidden: 'neutral', + 'connect-required': 'attention', + 'skill-required': 'attention', + unavailable: 'attention', + incomplete: 'attention' +} + +export function isTaskProviderChecking(readiness: TaskProviderReadiness): boolean { + return readiness.checking || readiness.skillChecking === true +} + +export function getTaskProviderCompletedSteps(readiness: TaskProviderReadiness): { + completed: number + total: number +} { + const skillRequired = readiness.skillInstalled !== undefined + const total = skillRequired ? 3 : 2 + let completed = 0 + if (readiness.connected) { + completed += 1 + } + if (skillRequired && readiness.skillInstalled) { + completed += 1 + } + if (readiness.visible) { + completed += 1 + } + return { completed, total } +} + +export function isTaskProviderReady(readiness: TaskProviderReadiness): boolean { + if (isTaskProviderChecking(readiness) || readiness.unavailable) { + return false + } + const { completed, total } = getTaskProviderCompletedSteps(readiness) + return completed === total +} + +export function getTaskProviderSetupStatus( + readiness: TaskProviderReadiness +): TaskProviderSetupStatus { + if (!readiness.visible) { + return 'hidden' + } + if (isTaskProviderChecking(readiness)) { + return 'checking' + } + if (readiness.unavailable) { + return 'unavailable' + } + if (isTaskProviderReady(readiness)) { + return 'ready' + } + if (!readiness.connected) { + return 'connect-required' + } + if (readiness.skillInstalled === false) { + return 'skill-required' + } + // Fallback if a future readiness field can leave connected+skill true but not ready. + return 'incomplete' +} + +// Exclude in-flight checks so a cold settings open does not flash a warning. +export function getIncompleteVisibleTaskProviders( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider[] { + return providers.filter((provider) => { + const readiness = readinessByProvider[provider] + if (!readiness.visible || isTaskProviderChecking(readiness)) { + return false + } + return !isTaskProviderReady(readiness) + }) +} + +// A provider nobody has touched is the shipped default, not a problem: settings +// expose all four, so treating "never connected" as a warning would fire on +// every fresh install. Setup counts as started once a credential or the skill +// landed. +export function hasStartedTaskProviderSetup(readiness: TaskProviderReadiness): boolean { + return readiness.connected || readiness.skillInstalled === true +} + +// Warn only about setup that started and stalled partway. +export function getStalledVisibleTaskProviders( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider[] { + return getIncompleteVisibleTaskProviders(providers, readinessByProvider).filter((provider) => + hasStartedTaskProviderSetup(readinessByProvider[provider]) + ) +} + +// Expand one unfinished provider because defaults expose every provider. +export function getAutoExpandedTaskProvider( + providers: readonly TaskProvider[], + readinessByProvider: Record +): TaskProvider | null { + return getIncompleteVisibleTaskProviders(providers, readinessByProvider)[0] ?? null +} + +// Auto-expand is a one-time first-open affordance. Once a card has claimed it, +// it keeps it for the life of the pane: releasing the slot when that provider +// finishes (or gets hidden) would let a later gh/glab preflight pop a different +// card open under the user, and could unmount an open install terminal. +export function resolveStickyAutoExpandedTaskProvider({ + providers, + readinessByProvider, + previousAutoExpanded +}: { + providers: readonly TaskProvider[] + readinessByProvider: Record + previousAutoExpanded: TaskProvider | null +}): TaskProvider | null { + return previousAutoExpanded ?? getAutoExpandedTaskProvider(providers, readinessByProvider) +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 9fce3fc84fc..1b3610f8466 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -126,6 +126,7 @@ describe('LinearIntegrationCard account scope', () => { const rendered = await renderCard() + expect(rendered.querySelector('[data-settings-section="integrations-linear"]')).not.toBeNull() expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`) expect(rendered.textContent).toContain( 'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.' diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index 6c2cc1b9575..e40c864a7ca 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -11,6 +11,7 @@ import { useIntegrationSubordinateRowClass } from './integration-card-presentati import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' +import { LINEAR_INTEGRATION_SECTION_ID } from './task-provider-integration-section-ids' import { translate } from '@/i18n/i18n' type VerificationResult = { state: 'ok' | 'error'; error?: string } @@ -68,6 +69,7 @@ export function LinearIntegrationCard(): React.JSX.Element { return ( } name="Linear" description={ diff --git a/src/renderer/src/components/settings/tasks-search.ts b/src/renderer/src/components/settings/tasks-search.ts index 0f5dd9a1a20..6115874f36e 100644 --- a/src/renderer/src/components/settings/tasks-search.ts +++ b/src/renderer/src/components/settings/tasks-search.ts @@ -2,24 +2,31 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +// Share keywords between the pane and settings-search index. +export const getTasksPaneSearchKeywords = createLocalizedCatalog(() => [ + ...translateSearchKeyword('auto.components.settings.tasks.search.2ec54bee51', 'tasks'), + ...translateSearchKeyword('auto.components.settings.tasks.search.cf0e3e0c2f', 'provider'), + ...translateSearchKeyword('auto.components.settings.tasks.search.3d81c26d78', 'source'), + ...translateSearchKeyword('auto.components.settings.tasks.search.c10ac2125e', 'github'), + ...translateSearchKeyword('auto.components.settings.tasks.search.11f001cdd4', 'gitlab'), + ...translateSearchKeyword('auto.components.settings.tasks.search.412ec3c702', 'linear'), + ...translateSearchKeyword('auto.components.settings.tasks.search.5430396e11', 'jira'), + ...translateSearchKeyword('auto.components.settings.tasks.search.604d8e4089', 'atlassian'), + ...translateSearchKeyword('auto.components.settings.tasks.search.44083ae418', 'display'), + ...translateSearchKeyword('auto.components.settings.tasks.search.58cda6f9c0', 'hide'), + ...translateSearchKeyword('auto.components.settings.tasks.search.setup', 'setup'), + ...translateSearchKeyword('auto.components.settings.tasks.search.apiKey', 'api key'), + ...translateSearchKeyword('auto.components.settings.tasks.search.skill', 'skill'), + ...translateSearchKeyword('auto.components.settings.tasks.search.connect', 'connect') +]) + export const getTasksPaneSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.tasks.search.5b8e4aace5', 'Task Providers'), description: translate( - 'auto.components.settings.tasks.search.765f0c544d', - 'Choose which task providers appear in the Tasks page and sidebar shortcuts.' + 'auto.components.settings.tasks.search.providersDescription', + 'Connect task providers, install the Linear agent skill, and choose what appears in Tasks.' ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.tasks.search.2ec54bee51', 'tasks'), - ...translateSearchKeyword('auto.components.settings.tasks.search.cf0e3e0c2f', 'provider'), - ...translateSearchKeyword('auto.components.settings.tasks.search.3d81c26d78', 'source'), - ...translateSearchKeyword('auto.components.settings.tasks.search.c10ac2125e', 'github'), - ...translateSearchKeyword('auto.components.settings.tasks.search.11f001cdd4', 'gitlab'), - ...translateSearchKeyword('auto.components.settings.tasks.search.412ec3c702', 'linear'), - ...translateSearchKeyword('auto.components.settings.tasks.search.5430396e11', 'jira'), - ...translateSearchKeyword('auto.components.settings.tasks.search.604d8e4089', 'atlassian'), - ...translateSearchKeyword('auto.components.settings.tasks.search.44083ae418', 'display'), - ...translateSearchKeyword('auto.components.settings.tasks.search.58cda6f9c0', 'hide') - ] + keywords: getTasksPaneSearchKeywords() } ]) diff --git a/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts new file mode 100644 index 00000000000..21a378a1b37 --- /dev/null +++ b/src/renderer/src/components/settings/use-linear-agent-skill-setup.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { + AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + ensureOrcaCliAvailableForAgentSkillTerminal +} from '@/lib/agent-skill-cli-prerequisite' +import { + LINEAR_AGENT_SKILL_NAMES, + ORCA_LINEAR_SKILL_INSTALL_COMMAND +} from '@/lib/agent-feature-install-commands' +import { getLinearAgentSkillUpdateTarget } from '@/lib/linear-agent-skill-update-command' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { + buildSkillCommandForRuntime, + ensureWslCliAvailableForAgentSkillTerminal, + getWslCliDistroRequest +} from './CliSkillRuntimeSetup' + +// Shared install/update wiring for Task Sources + Linear settings. +export function useLinearAgentSkillSetup(): { + installCommand: string + updateCommand: string + freshnessSkillName: string | undefined + skillInstalled: boolean + skillLoading: boolean + // Status surfaces (step badges, checklist pills) read this so a focus-triggered + // rescan does not flip a known result back to "checking". + skillChecking: boolean + installDisabled: boolean + error: string | null + terminalShellOverride: string | undefined + preInstallNotice: string + refreshSkill: () => Promise + getPrerequisiteStatus: () => Promise>> + onBeforeOpenTerminal: () => Promise +} { + const activeSkillRuntime = useActiveProjectSkillRuntime() + const { + installed: skillInstalled, + loading: skillLoading, + settled: skillSettled, + error: skillError, + skills: linearSkills, + refresh: refreshSkill + } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: activeSkillRuntime.discoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + + // Why: the built command also depends on the focused runtime environment, so + // memoizing it on the runtime alone can serve a stale Windows host command. + const installCommand = activeSkillRuntime.installDisabledReason + ? ORCA_LINEAR_SKILL_INSTALL_COMMAND + : buildSkillCommandForRuntime( + ORCA_LINEAR_SKILL_INSTALL_COMMAND, + activeSkillRuntime.agentRuntime + ) + const updateTarget = useMemo( + () => getLinearAgentSkillUpdateTarget(linearSkills, skillInstalled), + [linearSkills, skillInstalled] + ) + const updateCommand = activeSkillRuntime.installDisabledReason + ? updateTarget.command + : buildSkillCommandForRuntime(updateTarget.command, activeSkillRuntime.agentRuntime) + + // Freshness cannot verify WSL, so report presence there. + const freshnessSkillName = + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : updateTarget.skillName + + const getPrerequisiteStatus = useCallback( + () => + activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? window.api.cli.getWslInstallStatus( + getWslCliDistroRequest(activeSkillRuntime.agentRuntime) + ) + : window.api.cli.getInstallStatus(), + [activeSkillRuntime.agentRuntime] + ) + + const onBeforeOpenTerminal = useCallback(async () => { + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) + }, [activeSkillRuntime.agentRuntime]) + + const installDisabled = Boolean(activeSkillRuntime.installDisabledReason) + + return { + installCommand, + updateCommand, + freshnessSkillName, + skillInstalled, + skillLoading, + skillChecking: skillLoading && !skillSettled, + installDisabled, + error: activeSkillRuntime.installDisabledReason ?? skillError, + terminalShellOverride: activeSkillRuntime.terminalShellOverride, + preInstallNotice: AGENT_SKILL_CLI_PREREQUISITE_NOTICE, + refreshSkill, + getPrerequisiteStatus, + onBeforeOpenTerminal + } +} diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx new file mode 100644 index 00000000000..cb2cb636692 --- /dev/null +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskProvider } from '../../../../shared/types' +import type { TaskProviderReadiness } from './task-source-setup-state' +import { useTaskSourceProviderReadiness } from './use-task-source-provider-readiness' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + skill: { + installed: false, + loading: false, + settled: true, + error: null, + skills: [], + refresh: vi.fn() + } +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(mocks.state) +})) + +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalPreflightContext: () => null, + localPreflightContextKey: () => 'local' +})) + +vi.mock('@/lib/provider-runtime-context', () => ({ + getProviderRuntimeContextKey: () => 'local' +})) + +vi.mock('@/hooks/useActiveProjectSkillRuntime', () => ({ + useActiveProjectSkillRuntime: () => ({ installDisabledReason: null }) +})) + +vi.mock('@/hooks/useLinearProviderConnected', () => ({ + useLinearProviderConnected: () => Boolean(mocks.state.linearConnected) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['home'], + useInstalledAgentSkillNames: () => mocks.skill +})) + +const ALL_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear', 'jira'] + +let root: Root | null = null +let container: HTMLDivElement | null = null +let latest: Record | null = null + +function Probe({ visibleProviders }: { visibleProviders: readonly TaskProvider[] }): null { + latest = useTaskSourceProviderReadiness(visibleProviders) + return null +} + +async function renderProbe( + visibleProviders: readonly TaskProvider[] = ALL_PROVIDERS +): Promise { + if (!container) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render() + }) +} + +beforeEach(() => { + mocks.state = { + settings: {}, + preflightStatus: { + gh: { installed: true, authenticated: true }, + glab: { installed: true, authenticated: true } + }, + preflightStatusChecked: true, + preflightStatusContextKey: 'local', + preflightStatusError: null, + preflightStatusLoading: false, + jiraStatus: { connected: true }, + jiraStatusChecked: true, + jiraStatusContextKey: 'local', + linearStatusChecked: true, + linearStatusContextKey: 'local', + linearConnected: true + } + mocks.skill = { + installed: true, + loading: false, + settled: true, + error: null, + skills: [], + refresh: vi.fn() + } +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + latest = null +}) + +describe('useTaskSourceProviderReadiness', () => { + it('reports every provider connected once checks land cleanly', async () => { + await renderProbe() + + expect(latest?.github).toMatchObject({ connected: true, checking: false }) + expect(latest?.gitlab).toMatchObject({ connected: true, checking: false }) + expect(latest?.jira).toMatchObject({ connected: true, checking: false }) + expect(latest?.linear).toMatchObject({ + connected: true, + checking: false, + skillInstalled: true, + skillChecking: false + }) + }) + + it('does not read code-host connection facts out of a failed preflight snapshot', async () => { + // A failed refresh keeps the previous status object, so Integrations reports + // GitHub as disconnected; Task Sources must not disagree. + mocks.state.preflightStatusError = 'preflight failed' + + await renderProbe() + + expect(latest?.github.connected).toBe(false) + expect(latest?.gitlab.connected).toBe(false) + expect(latest?.github.unavailable).toBe(true) + expect(latest?.gitlab.unavailable).toBe(true) + }) + + it('keeps the known skill result while a focus-triggered rescan is in flight', async () => { + mocks.skill = { ...mocks.skill, loading: true, settled: true } + + await renderProbe() + + expect(latest?.linear).toMatchObject({ skillInstalled: true, skillChecking: false }) + }) + + it('reports the skill as checking until the first scan settles', async () => { + mocks.skill = { ...mocks.skill, installed: false, loading: true, settled: false } + + await renderProbe() + + expect(latest?.linear).toMatchObject({ skillInstalled: false, skillChecking: true }) + }) + + it('marks providers hidden when they are not in the visible list', async () => { + await renderProbe(['github', 'linear']) + + expect(latest?.github.visible).toBe(true) + expect(latest?.linear.visible).toBe(true) + expect(latest?.gitlab.visible).toBe(false) + expect(latest?.jira.visible).toBe(false) + }) + + it('recomputes visibility when the provider list changes', async () => { + await renderProbe(['github', 'linear']) + expect(latest?.jira.visible).toBe(false) + + await renderProbe(['github', 'linear', 'jira']) + expect(latest?.jira.visible).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/use-task-source-provider-readiness.ts b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts new file mode 100644 index 00000000000..97db089a677 --- /dev/null +++ b/src/renderer/src/components/settings/use-task-source-provider-readiness.ts @@ -0,0 +1,108 @@ +import { useMemo } from 'react' +import type { TaskProvider } from '../../../../shared/types' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' +import { LINEAR_AGENT_SKILL_NAMES } from '@/lib/agent-feature-install-commands' +import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { useAppStore } from '@/store' +import type { TaskProviderReadiness } from './task-source-setup-state' + +export function useTaskSourceProviderReadiness( + visibleProviders: readonly TaskProvider[] +): Record { + const settings = useAppStore((s) => s.settings) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) + const preflightStatusContextKey = useAppStore((s) => s.preflightStatusContextKey) + const preflightStatusError = useAppStore((s) => s.preflightStatusError) + const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) + const expectedPreflightContextKey = useAppStore((s) => + localPreflightContextKey(getLocalPreflightContext(s)) + ) + const jiraStatus = useAppStore((s) => s.jiraStatus) + const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked) + const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey) + const linearConnected = useLinearProviderConnected() + const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) + const linearStatusContextKey = useAppStore((s) => s.linearStatusContextKey) + const providerRuntimeContextKey = getProviderRuntimeContextKey(settings) + const activeSkillRuntime = useActiveProjectSkillRuntime() + + const { + installed: linearSkillInstalled, + loading: linearSkillLoading, + settled: linearSkillSettled + } = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: activeSkillRuntime.discoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + + const preflightCurrent = preflightStatusContextKey === expectedPreflightContextKey + const reviewChecking = preflightStatusLoading || !preflightStatusChecked || !preflightCurrent + // A failed preflight leaves the previous status object in place, so mirror + // Integrations and refuse to read connection facts out of a stale snapshot. + const reviewReadyForConnection = !reviewChecking && preflightStatusError === null + const reviewUnavailable = !reviewChecking && preflightStatusError !== null + const githubConnected = + reviewReadyForConnection && + preflightStatus?.gh?.installed === true && + preflightStatus.gh.authenticated === true + const gitlabConnected = + reviewReadyForConnection && + preflightStatus?.glab?.installed === true && + preflightStatus.glab.authenticated === true + const jiraChecking = jiraStatusContextKey !== providerRuntimeContextKey || !jiraStatusChecked + const jiraConnected = !jiraChecking && jiraStatus.connected === true + const linearChecking = + linearStatusContextKey !== providerRuntimeContextKey || !linearStatusChecked + // Normalization returns a new array, so memoize by provider contents. + const visibleProvidersKey = visibleProviders.join(',') + + return useMemo(() => { + const visible = new Set(visibleProvidersKey.split(',') as TaskProvider[]) + return { + github: { + connected: githubConnected, + checking: reviewChecking, + unavailable: reviewUnavailable, + visible: visible.has('github') + }, + gitlab: { + connected: gitlabConnected, + checking: reviewChecking, + unavailable: reviewUnavailable, + visible: visible.has('gitlab') + }, + linear: { + connected: linearConnected, + checking: linearChecking, + skillInstalled: linearSkillInstalled, + skillChecking: linearSkillLoading && !linearSkillSettled, + visible: visible.has('linear') + }, + jira: { + connected: jiraConnected, + checking: jiraChecking, + visible: visible.has('jira') + } + } + }, [ + githubConnected, + gitlabConnected, + jiraChecking, + jiraConnected, + linearChecking, + linearConnected, + linearSkillInstalled, + linearSkillLoading, + linearSkillSettled, + reviewChecking, + reviewUnavailable, + visibleProvidersKey + ]) +} diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts index 2e11a17ff03..3cee8c70dc1 100644 --- a/src/renderer/src/hooks/installed-agent-skills-change-event.ts +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -1 +1,2 @@ export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +export const INSTALLED_AGENT_SKILLS_REFRESHED_EVENT = 'orca:installed-agent-skills-refreshed' diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 67b6d1ae8da..9c78fa81e0b 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import type { SkillDiscoveryTarget } from '../../../shared/skills' @@ -25,6 +25,16 @@ const EMPTY_ACTIVE_PROJECT_SKILL_RUNTIME: ActiveProjectSkillRuntime = Object.fre installDisabledReason: null }) +// Why: on Windows the runtime resolution is rebuilt from scratch on every +// worktree-store change, so a same-runtime result still arrives with a fresh +// identity. Downstream skill discovery keys effects off `discoveryTarget`, so +// that churn would re-run a scan (and blink its loading state) per store update. +// Serializing the whole value (rather than picking fields) keeps the comparison +// honest if the resolution grows a field the runtime cache keys do not encode. +function activeProjectSkillRuntimeIdentity(runtime: ActiveProjectSkillRuntime): string { + return JSON.stringify(runtime) +} + export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { const runtimeState = useAppStore( useShallow((state) => ({ @@ -39,7 +49,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { const currentPlatform = getCurrentPlatform() const windowsCapabilities = useWindowsTerminalCapabilities(currentPlatform === 'win32') - return useMemo(() => { + const resolved = useMemo(() => { const projectRuntime = getLocalProjectExecutionRuntimeContext( runtimeState, undefined, @@ -75,6 +85,16 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { installDisabledReason: getProjectSkillInstallDisabledReason(projectRuntime) } }, [currentPlatform, runtimeState, windowsCapabilities]) + + // Content-equal runtimes keep one reference so effect keys do not thrash. + // Adjust during render (not a ref write) when serialized identity changes. + const [stable, setStable] = useState(resolved) + const stableIdentity = activeProjectSkillRuntimeIdentity(stable) + const resolvedIdentity = activeProjectSkillRuntimeIdentity(resolved) + if (stableIdentity !== resolvedIdentity) { + setStable(resolved) + } + return stableIdentity === resolvedIdentity ? stable : resolved } function getCurrentPlatform(): NodeJS.Platform { diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx index 008aef245d3..f6b446964ac 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx +++ b/src/renderer/src/hooks/useInstalledAgentSkills.react.test.tsx @@ -18,6 +18,7 @@ import { type InstalledAgentSkillState, _installedAgentSkillDiscoveryInternalsForTests, notifyInstalledAgentSkillsChanged, + notifyInstalledAgentSkillsRefreshed, useInstalledAgentSkillNames } from './useInstalledAgentSkills' @@ -288,6 +289,136 @@ describe('useInstalledAgentSkill', () => { }) }) + it('stays settled through a focus rescan so status surfaces do not flash', async () => { + const firstScan = deferred() + const focusScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(firstScan.promise) + .mockReturnValueOnce(focusScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + expect(latestState?.settled).toBe(false) + + firstScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await firstScan.promise + }) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(true) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) + + // The forced rescan is in flight, but the previous result is still current. + expect(latestState?.loading).toBe(true) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(true) + + focusScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await focusScan.promise + }) + expect(latestState?.settled).toBe(true) + }) + + it('reuses cached discovery when another surface finishes re-checking', async () => { + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockResolvedValue(discoveryResult([skill({ name: 'orca-linear' })])) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + await act(async () => { + await Promise.resolve() + }) + expect(discover).toHaveBeenCalledTimes(1) + + await act(async () => { + notifyInstalledAgentSkillsRefreshed() + await Promise.resolve() + }) + + expect(discover).toHaveBeenCalledTimes(1) + expect(latestState?.installed).toBe(true) + }) + + it('clears loading when a silent refresh supersedes an in-flight focus rescan', async () => { + const firstScan = deferred() + const focusScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(firstScan.promise) + .mockReturnValueOnce(focusScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + firstScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await firstScan.promise + }) + + await act(async () => { + window.dispatchEvent(new Event('focus')) + }) + expect(latestState?.loading).toBe(true) + + // The silent refresh takes over the generation, so it owns the spinner too. + await act(async () => { + notifyInstalledAgentSkillsRefreshed() + await Promise.resolve() + }) + await flushMicrotasks() + expect(latestState?.loading).toBe(false) + + focusScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await focusScan.promise + }) + expect(latestState?.loading).toBe(false) + }) + + it('reports unsettled again when the discovery target changes', async () => { + const hostScan = deferred() + const wslScan = deferred() + const discover = vi + .fn<(target?: SkillDiscoveryTarget) => Promise>() + .mockReturnValueOnce(hostScan.promise) + .mockReturnValueOnce(wslScan.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { discover } } + }) + + await renderProbe() + hostScan.resolve(discoveryResult([skill({ name: 'orca-linear' })])) + await act(async () => { + await hostScan.promise + }) + expect(latestState?.settled).toBe(true) + + await renderProbe({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(latestState?.settled).toBe(false) + + wslScan.resolve(discoveryResult([])) + await act(async () => { + await wslScan.promise + }) + expect(latestState?.settled).toBe(true) + expect(latestState?.installed).toBe(false) + }) + it('scans the connected remote runtime and keeps that result out of the local cache', async () => { const discover = vi .fn<(target?: SkillDiscoveryTarget) => Promise>() diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts index 28453f00489..df6ec1a1f4b 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.test.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.test.ts @@ -5,7 +5,8 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, _installedAgentSkillDiscoveryInternalsForTests, hasInstalledAgentSkill, - hasInstalledAgentSkillNamed + hasInstalledAgentSkillNamed, + notifyInstalledAgentSkillsRefreshed } from './useInstalledAgentSkills' afterEach(() => { @@ -223,6 +224,31 @@ describe('discoverInstalledAgentSkills', () => { await expect(forcedRefresh).resolves.toBe(freshResult) }) + it('lets completed-scan broadcasts reuse the cached result', async () => { + const discover = vi + .fn<() => Promise>() + .mockResolvedValue(discoveryResult([skill({ name: 'orca-linear' })])) + vi.stubGlobal('window', { + api: { skills: { discover } }, + dispatchEvent: vi.fn(), + CustomEvent: class {} + }) + + await _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true) + notifyInstalledAgentSkillsRefreshed() + const fromSubscribers = [1, 2, 3].map(() => + _installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(false) + ) + + expect(discover).toHaveBeenCalledTimes(1) + for (const pending of fromSubscribers) { + await expect(pending).resolves.toMatchObject({ + skills: [expect.objectContaining({ name: 'orca-linear' })] + }) + } + expect(discover).toHaveBeenCalledTimes(1) + }) + it('caches host and WSL discovery results separately', async () => { const hostResult = discoveryResult([skill({ name: 'host-skill' })]) const wslResult = discoveryResult([skill({ name: 'wsl-skill' })]) diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index 57e4b0cc7fe..482ea67386d 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -15,7 +15,10 @@ import { getSkillDiscoveryTargetKey, resetSkillDiscoveryCacheForTests } from './installed-agent-skill-discovery' -import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' +import { + INSTALLED_AGENT_SKILLS_CHANGED_EVENT, + INSTALLED_AGENT_SKILLS_REFRESHED_EVENT +} from './installed-agent-skills-change-event' import { useActiveSkillDiscoveryRuntimeTarget } from './use-active-skill-discovery-runtime-target' import { useMountedRef } from './useMountedRef' @@ -41,6 +44,9 @@ type InstalledAgentSkillMatchOptions = { export type InstalledAgentSkillState = { installed: boolean loading: boolean + // Why: a forced rescan keeps the previous result, so only the first scan per + // runtime-scoped target is genuinely unknown. + settled: boolean error: string | null skills: readonly DiscoveredSkill[] sources: readonly SkillDiscoverySource[] @@ -87,6 +93,12 @@ export function hasInstalledAgentSkillNamed( }) } +export function notifyInstalledAgentSkillsRefreshed(): void { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT)) + } +} + export const _installedAgentSkillDiscoveryInternalsForTests = { discoverInstalledAgentSkills, getSkillDiscoveryTargetKey, @@ -159,7 +171,7 @@ export function useInstalledAgentSkillNames( } const refresh = useCallback( - async (force = true): Promise => { + async (force = true, showLoading = true): Promise => { const requestDiscoveryTargetKey = discoveryTargetKey const requestGeneration = ++refreshGenerationRef.current const writeIfCurrent = (write: () => void): void => { @@ -178,9 +190,11 @@ export function useInstalledAgentSkillNames( }) return false } - writeIfCurrent(() => { - setLoading(true) - }) + if (showLoading) { + writeIfCurrent(() => { + setLoading(true) + }) + } if (!runtimeTarget) { // Why: stay in the loading state rather than scanning the wrong host and // reporting "not installed" before the owning runtime is known. @@ -205,6 +219,9 @@ export function useInstalledAgentSkillNames( ) }) } finally { + // Why: a silent refresh can supersede an in-flight loading one, whose own + // clear is then dropped by the generation guard. Only the winning + // generation clears, so it must clear regardless of its own showLoading. writeIfCurrent(() => { setLoading(false) }) @@ -233,13 +250,18 @@ export function useInstalledAgentSkillNames( const refreshFromExternalChange = (): void => { void refresh(true) } + const refreshFromCompletedScan = (): void => { + void refresh(false, false) + } // Why: skill install commands run outside React state, often in a terminal. // Refresh on focus and explicit install events so completion is detected. window.addEventListener('focus', refreshFromExternalChange) window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + window.addEventListener(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT, refreshFromCompletedScan) return () => { window.removeEventListener('focus', refreshFromExternalChange) window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refreshFromExternalChange) + window.removeEventListener(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT, refreshFromCompletedScan) } }, [enabled, refresh]) @@ -271,6 +293,7 @@ export function useInstalledAgentSkillNames( return { installed, loading: loadingForRender, + settled: enabled && resultForRender !== null, error: errorForRender, skills, sources, diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index df84c7ef0c7..15fdd70342f 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -197,7 +197,7 @@ export function buildSettingsNavigationMetadata({ title: translate('auto.hooks.useSettingsNavigationMetadata.linearTitle', 'Linear'), description: translate( 'auto.hooks.useSettingsNavigationMetadata.linearDescription', - 'Give agents the skill to read and update your linked Linear tickets.' + 'How Linear works in Orca, setup checklist, agent skill, and example prompts.' ), icon: LinearIcon, searchEntries: getLinearAgentSkillPaneSearchEntries(), @@ -325,8 +325,8 @@ export function buildSettingsNavigationMetadata({ id: 'tasks', title: translate('auto.hooks.useSettingsNavigationMetadata.85f4fd7710', 'Task Sources'), description: translate( - 'auto.hooks.useSettingsNavigationMetadata.5235c215ca', - 'Choose which task providers appear in the Tasks page and sidebar.' + 'auto.hooks.useSettingsNavigationMetadata.tasksDescription', + 'Connect providers, install the Linear skill, and choose what appears in Tasks.' ), icon: ListChecks, searchEntries: getTasksPaneSearchEntries(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b74621aad7..9706e8f1fb7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -640,7 +640,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "Attach the review link", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -648,8 +648,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "Link the pull or merge request to the Linear issue when you open it.", + "attachPrPrompt": "Use {{value0}} to attach this pull or merge request to the linked Linear issue.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -814,9 +814,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} hosts", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.", "pluginsTitle": "Plugins", - "pluginsDescription": "Install and manage experimental Orca plugins." + "pluginsDescription": "Install and manage experimental Orca plugins.", + "tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks." }, "useAppMenuPaste": { "pasteTooLarge": "Paste is too large." @@ -5316,7 +5317,9 @@ "copyCommandAria": "Copy command", "runCommandDescription": "Press Enter to run the command.", "5f818f12ab": "Preparing...", - "4c05b9d7cb": "Preparing setup terminal." + "4c05b9d7cb": "Preparing setup terminal.", + "installLabel": "Install", + "updateLabel": "Update" }, "AgentsPane": { "d83834f5e6": "Detecting installed agents…", @@ -6908,7 +6911,8 @@ "dev": "Dev Tools", "devDescription": "Dev-only tools for exercising UI states.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "How Linear works in Orca, setup checklist, agent skill, and example prompts.", + "tasksDescription": "Connect providers, install the Linear skill, and choose what appears in Tasks." }, "SettingsFormControls": { "42a4d15a30": "No matching fonts.", @@ -7137,18 +7141,34 @@ "53e9aabfc0": "Uses OpenSSH multiplexing when available. Turn off for hosts with custom SSH restrictions." }, "TasksPane": { - "3a72b9745e": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", "f71d8a9dd3": "Task Providers", - "71644aba56": "Choose which task providers appear in the Tasks page source picker and sidebar shortcuts. At least one provider must stay visible.", - "93e72ef659": "Task Sources", - "8e1305fcc6": "Show Jira in the Tasks source picker and sidebar shortcuts.", "6b23a34f6d": "Jira", - "e4170c9615": "Show Linear in the Tasks source picker and sidebar shortcuts.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "Show GitLab in the Tasks source picker and sidebar shortcuts.", "7c5d7fdc20": "GitLab", - "1db47236cd": "Show GitHub in the Tasks source picker and sidebar shortcuts.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "Connect {{provider}}", + "connectCodeHostDescription": "Install and authenticate the CLI under Integrations so Orca can load issues.", + "connectionCheckUnavailable": "Orca couldn't check this connection. Try again, or open Integrations for setup details.", + "retryConnection": "Try again", + "openIntegrations": "Integrations", + "connectInIntegrations": "Set up in Integrations", + "connectJiraTitle": "Connect Jira", + "connectJiraDescription": "Add a Jira Cloud site or self-hosted instance with an API token or PAT.", + "manageJira": "Manage keys", + "addJira": "Add Jira access", + "githubDescription": "Browse GitHub issues and start workspaces from them.", + "gitlabDescription": "Browse GitLab issues and start workspaces from them.", + "linearDescription": "Connect Linear, install the agent skill, and show it in Tasks.", + "jiraDescription": "Connect Jira Cloud or self-hosted Jira and show it in Tasks.", + "setupTitle": "Task management setup", + "setupDescription": "Finish connect + visibility for each provider in one place. Linear also needs the agent skill so coding agents can read and update tickets. At least one provider must stay visible.", + "incompleteBannerTitle": "Some visible providers still need setup", + "incompleteBannerBody": "Hide providers you do not use, or expand a card and finish its steps.", + "providersDescription": "Each card walks through connection (and skill, for Linear) plus whether it appears in Tasks.", + "integrationsHint": "Credentials for all providers also live under", + "integrationsLink": "Integrations", + "skillHint": ". After Linear is connected, usage examples stay under Settings → Linear.", + "incompleteBannerBodyWithLinear": "Hide providers you do not use, or expand a card and finish its steps. For Linear: API access, the agent skill, and Show in Tasks." }, "TerminalAppearanceSection": { "a14a427ae4": "Thickness of the pane divider line.", @@ -8794,8 +8814,12 @@ "3d81c26d78": "source", "cf0e3e0c2f": "provider", "2ec54bee51": "tasks", - "765f0c544d": "Choose which task providers appear in the Tasks page and sidebar shortcuts.", - "5b8e4aace5": "Task Providers" + "5b8e4aace5": "Task Providers", + "providersDescription": "Connect task providers, install the Linear agent skill, and choose what appears in Tasks.", + "setup": "setup", + "apiKey": "api key", + "skill": "skill", + "connect": "connect" } }, "terminal": { @@ -9520,13 +9544,13 @@ "notInstalled": "Not installed", "recheck": "Re-check", "installedDescription": "Agent skill installed. To update it, run:", - "description": "Let your agents read and edit Linear tasks.", + "description": "Let agents read and edit Linear tasks. Full guided setup (connect + skill + visibility) is under Settings → Task Sources.", "copyCommand": "Copy command" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear skill status, usage examples, and links to Task Sources setup." } } } @@ -9616,15 +9640,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", + "description": "How Linear works in Orca: browse issues, start linked workspaces, and let agents update tickets with /orca-linear.", "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", + "howToUse": "Example prompts", + "howToUseDescription": "Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.", "terminalTitle": "Linear skill setup", "terminalAriaLabel": "Linear skill install terminal", "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "manageConnectionLink": "Integrations" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9941,6 +9964,69 @@ "development": "development plugins" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Connect Linear", + "setupConnectBody": "Personal API key so Orca can list issues and open linked workspaces.", + "manageKeys": "Manage keys", + "addAccess": "Add access", + "setupSkillTitle": "2. Install the agent skill", + "setupSkillBody": "Gives coding agents /orca-linear for reading, updates, triage, and attaching pull or merge requests.", + "setupVisibleTitle": "3. Show Linear in Tasks", + "setupVisibleBody": "Keeps Linear in the Tasks source picker and sidebar shortcuts.", + "openTaskSources": "Task Sources", + "setupTitle": "Setup checklist", + "setupBody": "All three are required for the full Tasks + agent loop. First-time path is also under Task Sources.", + "setupReady": "All set", + "setupProgress": "{{done}} of {{total}} ready", + "notesTitle": "Good to know", + "notesIntro": "Quick reminders once Linear is connected and the skill is installed.", + "noteLinkedTitle": "Start from a Linear issue", + "noteLinkedBody": "Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.", + "noteSlashTitle": "Mention /orca-linear", + "noteSlashBody": "In chat, use /orca-linear (or ask in plain language) so the agent loads the skill for that turn.", + "noteKeysTitle": "Keys follow the runtime", + "noteKeysBody": "API keys and workspaces are stored for the active runtime.", + "noteVisibilityTitle": "Hiding ≠ disconnect", + "noteVisibilityBody": "Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.", + "setupChecking": "Checking…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Connect Linear", + "connectDescription": "Add a Personal API key so Orca can browse issues and open workspaces with ticket context.", + "manageAccess": "Manage keys", + "addAccess": "Add Linear access", + "connectedHint": "Workspaces and keys are stored for the active runtime. You can add more access any time.", + "recheck": "Re-check connection", + "skillTitle": "Install Linear agent skill", + "skillDescription": "Gives agents /orca-linear to read tickets, post updates, move states, and attach pull or merge requests.", + "skillBlocked": "Connect Linear first, then install the skill for agents.", + "skillPanelTitle": "Linear skill", + "terminalTitle": "Linear skill setup", + "terminalAriaLabel": "Linear skill install terminal", + "showDescription": "Include Linear in the Tasks page source picker and sidebar shortcuts." + }, + "TaskSourceProviderCard": { + "statusChecking": "Checking…", + "statusReady": "Ready", + "statusConnectRequired": "Connect required", + "statusSkillRequired": "Skill required", + "statusUnavailable": "Status unavailable", + "statusHidden": "Hidden from Tasks", + "statusIncomplete": "Needs setup", + "collapseSetup": "Collapse {{provider}} setup steps", + "expandSetup": "Show {{provider}} setup steps" + }, + "TaskSourceShowInTasksStep": { + "shown": "Shown", + "show": "Show", + "hide": "Hide", + "hideProviderAction": "Hide {{provider}} from Tasks", + "showProviderAction": "Show {{provider}} in Tasks", + "lastProviderAction": "{{provider}} is shown in Tasks. At least one provider must stay visible.", + "lastProviderHint": "At least one provider must stay visible in Tasks.", + "title": "Show in Tasks", + "description": "Include this provider in the Tasks source picker and sidebar shortcuts." + }, "RuntimeHostAccessForm": { "getLink": "Get an access link from the other host", "stepOpenShare": "Open Settings → Remote Orca Servers → Share this host.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e29d1ebcee4..ee83b6d36a4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "Adjuntar el enlace de revisión", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "Vincula la pull request o merge request con la incidencia de Linear al abrirla.", + "attachPrPrompt": "Usa {{value0}} para adjuntar esta pull request o merge request a la incidencia de Linear vinculada.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} hosts", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Cómo funciona Linear en Orca, lista de configuración, habilidad del agente y ejemplos de prompts.", "pluginsTitle": "Plugins", - "pluginsDescription": "Instala y administra plugins experimentales de Orca." + "pluginsDescription": "Instala y administra plugins experimentales de Orca.", + "tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas." }, "useAppMenuPaste": { "pasteTooLarge": "El contenido pegado es demasiado grande." @@ -5288,7 +5289,9 @@ "copyCommandAria": "Copiar comando", "runCommandDescription": "Presiona Enter para ejecutar el comando.", "5f818f12ab": "Preparando...", - "4c05b9d7cb": "Preparando el terminal de configuración." + "4c05b9d7cb": "Preparando el terminal de configuración.", + "installLabel": "Instalar", + "updateLabel": "Actualizar" }, "AgentsPane": { "d83834f5e6": "Detectando agents instalados…", @@ -6839,7 +6842,8 @@ "dev": "Herramientas de desarrollo", "devDescription": "Herramientas solo de desarrollo para probar estados de la UI.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Cómo funciona Linear en Orca, lista de configuración, habilidad del agente y ejemplos de prompts.", + "tasksDescription": "Conecta proveedores, instala la habilidad de Linear y elige qué aparece en Tareas." }, "SettingsFormControls": { "42a4d15a30": "No hay fuentes coincidentes.", @@ -7068,18 +7072,34 @@ "53e9aabfc0": "Usa multiplexación de OpenSSH cuando está disponible. Desactívalo para hosts con restricciones SSH personalizadas." }, "TasksPane": { - "3a72b9745e": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", "f71d8a9dd3": "Proveedores de tareas", - "71644aba56": "Elige qué proveedores de tareas aparecen en el selector de fuentes de la página Tareas y en los accesos directos de la barra lateral. Al menos un proveedor debe permanecer visible.", - "93e72ef659": "Fuentes de tareas", - "8e1305fcc6": "Muestra Jira en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "6b23a34f6d": "Jira", - "e4170c9615": "Muestra Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "Muestra GitLab en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", "7c5d7fdc20": "GitLab", - "1db47236cd": "Muestra GitHub en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "Conectar {{provider}}", + "connectCodeHostDescription": "Instala y autentica la CLI en Integraciones para que Orca pueda cargar incidencias.", + "connectionCheckUnavailable": "Orca no pudo comprobar esta conexión. Inténtalo de nuevo o abre Integraciones para ver los detalles de configuración.", + "retryConnection": "Intentar de nuevo", + "openIntegrations": "Integraciones", + "connectInIntegrations": "Configurar en Integraciones", + "connectJiraTitle": "Conectar Jira", + "connectJiraDescription": "Añade un sitio de Jira Cloud o una instancia autohospedada con un token de API o PAT.", + "manageJira": "Administrar claves", + "addJira": "Añadir acceso a Jira", + "githubDescription": "Explora incidencias de GitHub e inicia espacios de trabajo desde ellas.", + "gitlabDescription": "Explora incidencias de GitLab e inicia espacios de trabajo desde ellas.", + "linearDescription": "Conecta Linear, instala la habilidad del agente y muéstralo en Tareas.", + "jiraDescription": "Conecta Jira Cloud o Jira autohospedado y muéstralo en Tareas.", + "setupTitle": "Configuración de gestión de tareas", + "setupDescription": "Completa la conexión y la visibilidad de cada proveedor en un solo lugar. Linear también necesita la habilidad del agente para que los agentes puedan leer y actualizar incidencias. Al menos un proveedor debe permanecer visible.", + "incompleteBannerTitle": "Algunos proveedores visibles aún necesitan configuración", + "incompleteBannerBody": "Oculta los proveedores que no uses o expande una tarjeta y completa sus pasos.", + "providersDescription": "Cada tarjeta guía la conexión (y la habilidad, en Linear) y si aparece en Tareas.", + "integrationsHint": "Las credenciales de todos los proveedores también están en", + "integrationsLink": "Integraciones", + "skillHint": ". Después de conectar Linear, los ejemplos de uso están en Ajustes → Linear.", + "incompleteBannerBodyWithLinear": "Oculta los proveedores que no uses o expande una tarjeta y completa sus pasos. Para Linear: acceso a la API, la habilidad del agente y Mostrar en Tareas." }, "TerminalAppearanceSection": { "a14a427ae4": "Grosor de la línea divisoria del panel.", @@ -8724,8 +8744,12 @@ "3d81c26d78": "fuente", "cf0e3e0c2f": "proveedor", "2ec54bee51": "tareas", - "765f0c544d": "Elige qué proveedores de tareas aparecen en la página Tareas y en los accesos directos de la barra lateral.", - "5b8e4aace5": "Proveedores de tareas" + "5b8e4aace5": "Proveedores de tareas", + "providersDescription": "Conecta proveedores de tareas, instala la habilidad de agente de Linear y elige qué aparece en Tareas.", + "setup": "configuración", + "apiKey": "clave de API", + "skill": "habilidad", + "connect": "conectar" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "No instalado", "recheck": "Reverificar", "installedDescription": "Habilidad del agente instalada. Para actualizarla, ejecuta:", - "description": "Permite que tus agentes lean y editen tareas de Linear.", + "description": "Permite que los agentes lean y editen tareas de Linear. La configuración guiada completa (conexión + habilidad + visibilidad) está en Ajustes → Fuentes de tareas.", "copyCommand": "Copiar comando" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Estado de la habilidad de Linear, ejemplos de uso y enlaces a la configuración de Fuentes de tareas." } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Cómo funciona Linear en Orca: explora incidencias, inicia espacios de trabajo vinculados y permite que los agentes actualicen incidencias con /orca-linear.", + "skillTitle": "Habilidad de Linear", + "howToUse": "Prompts de ejemplo", + "howToUseDescription": "Haz clic en una tarjeta para copiar un prompt. Úsalos en un espacio de trabajo vinculado a Linear después de instalar la habilidad.", + "terminalTitle": "Configuración de la habilidad de Linear", + "terminalAriaLabel": "Terminal de instalación de la habilidad de Linear", + "manageConnectionHint": "Revisa los espacios de trabajo de Linear conectados y las claves de API en", + "manageConnectionLink": "Integraciones" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "plugins de desarrollo" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Conectar Linear", + "setupConnectBody": "Una clave de API personal para que Orca pueda listar incidencias y abrir espacios de trabajo vinculados.", + "manageKeys": "Administrar claves", + "addAccess": "Añadir acceso", + "setupSkillTitle": "2. Instalar la habilidad del agente", + "setupSkillBody": "Proporciona /orca-linear a los agentes para leer, actualizar, clasificar y adjuntar solicitudes de cambio.", + "setupVisibleTitle": "3. Mostrar Linear en Tareas", + "setupVisibleBody": "Mantiene Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral.", + "openTaskSources": "Fuentes de tareas", + "setupTitle": "Lista de configuración", + "setupBody": "Los tres pasos son necesarios para el flujo completo entre Tareas y agentes. La configuración inicial también está en Fuentes de tareas.", + "setupReady": "Todo listo", + "setupProgress": "{{done}} de {{total}} listos", + "notesTitle": "Conviene saberlo", + "notesIntro": "Recordatorios rápidos después de conectar Linear e instalar la habilidad.", + "noteLinkedTitle": "Comienza desde una incidencia de Linear", + "noteLinkedBody": "Las acciones de incidencias funcionan mejor en un espacio de trabajo creado desde Tareas, para mantener la incidencia vinculada como contexto.", + "noteSlashTitle": "Menciona /orca-linear", + "noteSlashBody": "En el chat, usa /orca-linear (o pide la acción en lenguaje natural) para que el agente cargue la habilidad durante ese turno.", + "noteKeysTitle": "Las claves siguen al entorno de ejecución", + "noteKeysBody": "Las claves de API y los espacios de trabajo se guardan para el entorno de ejecución activo.", + "noteVisibilityTitle": "Ocultar no desconecta", + "noteVisibilityBody": "Ocultar Linear en Fuentes de tareas solo lo quita del selector. No elimina la clave ni la habilidad.", + "setupChecking": "Comprobando…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Conectar Linear", + "connectDescription": "Añade una clave de API personal para que Orca pueda explorar incidencias y abrir espacios de trabajo con su contexto.", + "manageAccess": "Administrar claves", + "addAccess": "Añadir acceso a Linear", + "connectedHint": "Los espacios de trabajo y las claves se guardan para el entorno de ejecución activo. Puedes añadir más accesos en cualquier momento.", + "recheck": "Volver a comprobar la conexión", + "skillTitle": "Instalar la habilidad de agente de Linear", + "skillDescription": "Proporciona /orca-linear a los agentes para leer incidencias, publicar actualizaciones, cambiar estados y adjuntar solicitudes de cambio.", + "skillBlocked": "Conecta Linear primero y luego instala la habilidad para los agentes.", + "skillPanelTitle": "Habilidad de Linear", + "terminalTitle": "Configuración de la habilidad de Linear", + "terminalAriaLabel": "Terminal de instalación de la habilidad de Linear", + "showDescription": "Incluye Linear en el selector de fuentes de Tareas y en los accesos directos de la barra lateral." + }, + "TaskSourceProviderCard": { + "statusChecking": "Comprobando…", + "statusReady": "Listo", + "statusConnectRequired": "Requiere conexión", + "statusSkillRequired": "Requiere la habilidad", + "statusUnavailable": "Estado no disponible", + "statusHidden": "Oculto en Tareas", + "statusIncomplete": "Necesita configuración", + "collapseSetup": "Contraer los pasos de configuración de {{provider}}", + "expandSetup": "Mostrar los pasos de configuración de {{provider}}" + }, + "TaskSourceShowInTasksStep": { + "shown": "Visible", + "show": "Mostrar", + "hide": "Ocultar", + "hideProviderAction": "Ocultar {{provider}} de Tareas", + "showProviderAction": "Mostrar {{provider}} en Tareas", + "lastProviderAction": "{{provider}} se muestra en Tareas. Al menos un proveedor debe permanecer visible.", + "lastProviderHint": "Al menos un proveedor debe permanecer visible en Tareas.", + "title": "Mostrar en Tareas", + "description": "Incluye este proveedor en el selector de fuentes de Tareas y en los accesos directos de la barra lateral." + }, "RuntimeHostAccessForm": { "getLink": "Obtener un enlace de acceso del otro host", "stepOpenShare": "Abre Ajustes → Servidores remotos de Orca → Compartir este host.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8def5244244..de0d97977c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "レビューリンクを添付", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "プルリクエストまたはマージリクエストを作成したら Linear の課題にリンクします。", + "attachPrPrompt": "{{value0}} を使って、このプルリクエストまたはマージリクエストをリンク済みの Linear 課題に添付してください。", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} 台のホスト", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Orca での Linear の仕組み、セットアップチェックリスト、エージェントスキル、プロンプト例。", "pluginsTitle": "プラグイン", - "pluginsDescription": "試験的な Orca プラグインをインストールして管理します。" + "pluginsDescription": "試験的な Orca プラグインをインストールして管理します。", + "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。" }, "useAppMenuPaste": { "pasteTooLarge": "貼り付け内容が大きすぎます。" @@ -5273,7 +5274,9 @@ "copyCommandAria": "コマンドをコピー", "runCommandDescription": "Enter を押してコマンドを実行します。", "5f818f12ab": "準備中...", - "4c05b9d7cb": "セットアップ用ターミナルを準備しています。" + "4c05b9d7cb": "セットアップ用ターミナルを準備しています。", + "installLabel": "インストール", + "updateLabel": "更新" }, "AgentsPane": { "d83834f5e6": "インストールされている agents を検出しています…", @@ -6861,7 +6864,8 @@ "dev": "Dev Tools", "devDescription": "Dev-only tools for exercising UI states.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Orca での Linear の仕組み、セットアップチェックリスト、エージェントスキル、プロンプト例。", + "tasksDescription": "プロバイダーを接続し、Linear スキルをインストールして、タスクに表示する項目を選択します。" }, "SettingsFormControls": { "42a4d15a30": "一致するフォントがありません。", @@ -7090,18 +7094,34 @@ "53e9aabfc0": "利用可能な場合は OpenSSH の多重化を使用します。独自の SSH 制限があるホストではオフにしてください。" }, "TasksPane": { - "3a72b9745e": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", "f71d8a9dd3": "タスクプロバイダー", - "71644aba56": "[タスク] ページのソース ピッカーとサイドバーのショートカットに表示するタスク プロバイダーを選択します。少なくとも 1 つのプロバイダーが表示されたままでなければなりません。", - "93e72ef659": "タスクソース", - "8e1305fcc6": "タスクのソース ピッカーとサイドバーのショートカットに Jira を表示します。", "6b23a34f6d": "Jira", - "e4170c9615": "タスクのソース ピッカーとサイドバーのショートカットに Linear を表示します。", "09ae2d7c51": "Linear", - "dd67a1b6e1": "タスクのソース ピッカーとサイドバーのショートカットに GitLab を表示します。", "7c5d7fdc20": "GitLab", - "1db47236cd": "タスクのソース ピッカーとサイドバーのショートカットに GitHub を表示します。", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "{{provider}} に接続", + "connectCodeHostDescription": "Orca が課題を読み込めるように、連携で CLI をインストールして認証します。", + "connectionCheckUnavailable": "Orca はこの接続を確認できませんでした。もう一度試すか、連携を開いてセットアップの詳細を確認してください。", + "retryConnection": "もう一度試す", + "openIntegrations": "連携", + "connectInIntegrations": "連携で設定", + "connectJiraTitle": "Jira に接続", + "connectJiraDescription": "API トークンまたは PAT を使って Jira Cloud サイトまたはセルフホスト環境を追加します。", + "manageJira": "キーを管理", + "addJira": "Jira アクセスを追加", + "githubDescription": "GitHub の Issue を参照し、そこからワークスペースを開始します。", + "gitlabDescription": "GitLab の Issue を参照し、そこからワークスペースを開始します。", + "linearDescription": "Linear に接続し、エージェントスキルをインストールして、タスクに表示します。", + "jiraDescription": "Jira Cloud またはセルフホスト版 Jira に接続し、タスクに表示します。", + "setupTitle": "タスク管理のセットアップ", + "setupDescription": "各プロバイダーの接続と表示設定を 1 か所で完了できます。Linear では、エージェントが課題を読み書きするためのスキルも必要です。少なくとも 1 つのプロバイダーを表示する必要があります。", + "incompleteBannerTitle": "表示中の一部のプロバイダーは設定が必要です", + "incompleteBannerBody": "使用しないプロバイダーを非表示にするか、カードを展開して手順を完了してください。", + "providersDescription": "各カードで接続、タスクへの表示、Linear の場合はスキル設定を進められます。", + "integrationsHint": "すべてのプロバイダーの認証情報は次の場所でも管理できます:", + "integrationsLink": "連携", + "skillHint": "。Linear の接続後、使用例は設定 → Linear で確認できます。", + "incompleteBannerBodyWithLinear": "使用しないプロバイダーを非表示にするか、カードを展開して手順を完了してください。Linear では API アクセス、エージェントスキル、タスクへの表示が必要です。" }, "TerminalAppearanceSection": { "a14a427ae4": "ペイン分割線の太さ。", @@ -8746,8 +8766,12 @@ "3d81c26d78": "ソース", "cf0e3e0c2f": "プロバイダー", "2ec54bee51": "タスク", - "765f0c544d": "[タスク] ページとサイドバーのショートカットに表示するタスク プロバイダーを選択します。", - "5b8e4aace5": "タスクプロバイダー" + "5b8e4aace5": "タスクプロバイダー", + "providersDescription": "タスクプロバイダーを接続し、Linear エージェントスキルをインストールして、タスクに表示する項目を選択します。", + "setup": "セットアップ", + "apiKey": "APIキー", + "skill": "スキル", + "connect": "接続" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "未インストール", "recheck": "再確認", "installedDescription": "エージェントスキルがインストールされました。更新するには以下を実行してください:", - "description": "エージェントにLinearタスクの読み取りと編集を許可します。", + "description": "エージェントが Linear の課題を読み書きできるようにします。接続、スキル、表示設定を含むガイドは、設定 → タスクソースにあります。", "copyCommand": "コマンドをコピー" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear スキルの状態、使用例、タスクソース設定へのリンク。" } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Orca で Linear の課題を参照し、リンク済みワークスペースを開始して、/orca-linear でエージェントから更新できます。", + "skillTitle": "Linear スキル", + "howToUse": "プロンプト例", + "howToUseDescription": "カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークスペースで使用してください。", + "terminalTitle": "Linear スキルのセットアップ", + "terminalAriaLabel": "Linear スキルのインストールターミナル", + "manageConnectionHint": "接続済みの Linear ワークスペースと API キーは次の場所で確認できます:", + "manageConnectionLink": "連携" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "開発プラグイン" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Linear に接続", + "setupConnectBody": "Orca が課題を一覧表示し、リンク済みワークスペースを開くための個人 API キー。", + "manageKeys": "キーを管理", + "addAccess": "アクセスを追加", + "setupSkillTitle": "2. エージェントスキルをインストール", + "setupSkillBody": "エージェントが /orca-linear で課題の参照、更新、トリアージ、プル/マージリクエストの添付を行えるようにします。", + "setupVisibleTitle": "3. タスクに Linear を表示", + "setupVisibleBody": "Linear をタスクのソース選択とサイドバーのショートカットに表示します。", + "openTaskSources": "タスクソース", + "setupTitle": "セットアップチェックリスト", + "setupBody": "タスクとエージェントの一連の流れには 3 つの手順が必要です。初回設定はタスクソースでも行えます。", + "setupReady": "設定完了", + "setupProgress": "{{total}} 件中 {{done}} 件が完了", + "notesTitle": "知っておくと便利", + "notesIntro": "Linear の接続とスキルのインストール後に役立つポイントです。", + "noteLinkedTitle": "Linear の課題から開始", + "noteLinkedBody": "タスクから作成したワークスペースでは課題がコンテキストとしてリンクされるため、課題操作が最もスムーズです。", + "noteSlashTitle": "/orca-linear を指定", + "noteSlashBody": "チャットで /orca-linear を使うか自然言語で依頼すると、そのターンでエージェントがスキルを読み込みます。", + "noteKeysTitle": "キーは実行環境ごとに保存", + "noteKeysBody": "API キーとワークスペースはアクティブな実行環境に保存されます。", + "noteVisibilityTitle": "非表示にしても接続は維持", + "noteVisibilityBody": "タスクソースで Linear を非表示にしても、選択肢から外れるだけで、キーやスキルは削除されません。", + "setupChecking": "確認中…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Linear に接続", + "connectDescription": "Orca が課題を参照し、そのコンテキストでワークスペースを開けるように個人 API キーを追加します。", + "manageAccess": "キーを管理", + "addAccess": "Linear アクセスを追加", + "connectedHint": "ワークスペースとキーはアクティブな実行環境に保存されます。アクセスはいつでも追加できます。", + "recheck": "接続を再確認", + "skillTitle": "Linear エージェントスキルをインストール", + "skillDescription": "エージェントが /orca-linear で課題の参照、更新の投稿、状態変更、プル/マージリクエストの添付を行えるようにします。", + "skillBlocked": "先に Linear に接続してから、エージェント用スキルをインストールしてください。", + "skillPanelTitle": "Linear スキル", + "terminalTitle": "Linear スキルのセットアップ", + "terminalAriaLabel": "Linear スキルのインストールターミナル", + "showDescription": "Linear をタスクのソース選択とサイドバーのショートカットに表示します。" + }, + "TaskSourceProviderCard": { + "statusChecking": "確認中…", + "statusReady": "準備完了", + "statusConnectRequired": "接続が必要", + "statusSkillRequired": "スキルが必要", + "statusUnavailable": "ステータスを確認できません", + "statusHidden": "タスクで非表示", + "statusIncomplete": "設定が必要", + "collapseSetup": "{{provider}} のセットアップ手順を折りたたむ", + "expandSetup": "{{provider}} のセットアップ手順を表示" + }, + "TaskSourceShowInTasksStep": { + "shown": "表示中", + "show": "表示", + "hide": "非表示", + "hideProviderAction": "{{provider}} をタスクから非表示", + "showProviderAction": "{{provider}} をタスクに表示", + "lastProviderAction": "{{provider}} はタスクに表示されています。少なくとも 1 つのプロバイダーを表示する必要があります。", + "lastProviderHint": "タスクには少なくとも 1 つのプロバイダーを表示する必要があります。", + "title": "タスクに表示", + "description": "このプロバイダーをタスクのソース選択とサイドバーのショートカットに表示します。" + }, "RuntimeHostAccessForm": { "getLink": "もう一方のホストからアクセスリンクを取得", "stepOpenShare": "設定 → リモート Orca サーバー → このホストを共有 を開きます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e1cc97e3a0d..2670e6df975 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "리뷰 링크 첨부", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "풀 또는 머지 리퀘스트를 만들 때 Linear 이슈에 연결하세요.", + "attachPrPrompt": "{{value0}}을 사용해 이 풀 또는 머지 리퀘스트를 연결된 Linear 이슈에 첨부하세요.", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "호스트 {{value0}}개", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", "pluginsTitle": "플러그인", - "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다." + "pluginsDescription": "실험적인 Orca 플러그인을 설치하고 관리합니다.", + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." }, "useAppMenuPaste": { "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." @@ -5273,7 +5274,9 @@ "copyCommandAria": "명령 복사", "runCommandDescription": "Enter를 눌러 명령을 실행하세요.", "5f818f12ab": "준비 중...", - "4c05b9d7cb": "설정 터미널을 준비하고 있습니다." + "4c05b9d7cb": "설정 터미널을 준비하고 있습니다.", + "installLabel": "설치", + "updateLabel": "업데이트" }, "AgentsPane": { "d83834f5e6": "설치된 agents 감지 중…", @@ -6824,7 +6827,8 @@ "dev": "Dev Tools", "devDescription": "UI 상태를 시험해 보기 위한 개발 전용 도구입니다.", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets." + "linearDescription": "Orca에서 Linear가 작동하는 방식, 설정 체크리스트, 에이전트 스킬, 프롬프트 예시입니다.", + "tasksDescription": "제공업체를 연결하고 Linear 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요." }, "SettingsFormControls": { "42a4d15a30": "일치하는 글꼴이 없습니다.", @@ -7053,18 +7057,34 @@ "53e9aabfc0": "사용 가능한 경우 OpenSSH 멀티플렉싱을 사용합니다. 사용자 지정 SSH 제한이 있는 호스트에서는 끄세요." }, "TasksPane": { - "3a72b9745e": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", "f71d8a9dd3": "작업 제공자", - "71644aba56": "작업 페이지 소스 선택기 및 사이드바 바로가기에 표시할 작업 제공자를 선택합니다. 하나 이상의 제공업체가 계속 표시되어야 합니다.", - "93e72ef659": "작업 소스", - "8e1305fcc6": "작업 소스 선택기 및 사이드바 바로가기에 Jira를 표시합니다.", "6b23a34f6d": "Jira", - "e4170c9615": "작업 소스 선택기 및 사이드바 바로가기에 Linear를 표시합니다.", "09ae2d7c51": "Linear", - "dd67a1b6e1": "작업 소스 선택기 및 사이드바 바로가기에 GitLab을 표시합니다.", "7c5d7fdc20": "GitLab", - "1db47236cd": "작업 소스 선택기 및 사이드바 바로가기에 GitHub를 표시합니다.", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "{{provider}} 연결", + "connectCodeHostDescription": "Orca가 이슈를 불러올 수 있도록 연동에서 CLI를 설치하고 인증하세요.", + "connectionCheckUnavailable": "Orca가 이 연결을 확인하지 못했습니다. 다시 시도하거나 연동을 열어 설정 세부 정보를 확인하세요.", + "retryConnection": "다시 시도", + "openIntegrations": "연동", + "connectInIntegrations": "연동에서 설정", + "connectJiraTitle": "Jira 연결", + "connectJiraDescription": "API 토큰 또는 PAT로 Jira Cloud 사이트나 자체 호스팅 인스턴스를 추가하세요.", + "manageJira": "키 관리", + "addJira": "Jira 액세스 추가", + "githubDescription": "GitHub 이슈를 탐색하고 해당 이슈에서 워크스페이스를 시작하세요.", + "gitlabDescription": "GitLab 이슈를 탐색하고 해당 이슈에서 워크스페이스를 시작하세요.", + "linearDescription": "Linear를 연결하고 에이전트 스킬을 설치한 다음 작업에 표시하세요.", + "jiraDescription": "Jira Cloud 또는 자체 호스팅 Jira를 연결하고 작업에 표시하세요.", + "setupTitle": "작업 관리 설정", + "setupDescription": "각 제공업체의 연결과 표시 설정을 한곳에서 완료하세요. Linear에서는 에이전트가 이슈를 읽고 업데이트할 수 있도록 에이전트 스킬도 필요합니다. 제공업체는 하나 이상 표시되어야 합니다.", + "incompleteBannerTitle": "표시 중인 일부 제공업체에 설정이 필요합니다", + "incompleteBannerBody": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요.", + "providersDescription": "각 카드에서 연결, 작업 표시 여부, Linear의 경우 스킬 설정까지 진행할 수 있습니다.", + "integrationsHint": "모든 제공업체의 자격 증명은 다음에서도 관리할 수 있습니다:", + "integrationsLink": "연동", + "skillHint": ". Linear를 연결한 후 사용 예시는 설정 → Linear에서 확인할 수 있습니다.", + "incompleteBannerBodyWithLinear": "사용하지 않는 제공업체를 숨기거나 카드를 펼쳐 단계를 완료하세요. Linear에는 API 액세스, 에이전트 스킬, 작업에 표시가 필요합니다." }, "TerminalAppearanceSection": { "a14a427ae4": "창 구분선의 두께입니다.", @@ -8709,8 +8729,12 @@ "3d81c26d78": "소스", "cf0e3e0c2f": "공급자", "2ec54bee51": "작업", - "765f0c544d": "작업 페이지와 사이드바 바로가기에 표시할 작업 제공자를 선택합니다.", - "5b8e4aace5": "작업 제공자" + "5b8e4aace5": "작업 제공자", + "providersDescription": "작업 제공업체를 연결하고 Linear 에이전트 스킬을 설치한 다음 작업에 표시할 항목을 선택하세요.", + "setup": "설정", + "apiKey": "API 키", + "skill": "스킬", + "connect": "연결" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "설치되지 않음", "recheck": "다시 확인", "installedDescription": "에이전트 스킬이 설치되었습니다. 업데이트하려면 다음을 실행하세요:", - "description": "에이전트가 Linear 작업을 읽고 편집할 수 있도록 합니다.", + "description": "에이전트가 Linear 작업을 읽고 편집할 수 있도록 합니다. 전체 가이드 설정(연결 + 스킬 + 표시)은 설정 → 작업 소스에 있습니다.", "copyCommand": "명령 복사" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear 스킬 상태, 사용 예시, 작업 소스 설정 링크입니다." } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "Orca에서 Linear 이슈를 탐색하고 연결된 워크스페이스를 시작하며 /orca-linear로 에이전트가 이슈를 업데이트할 수 있습니다.", + "skillTitle": "Linear 스킬", + "howToUse": "프롬프트 예시", + "howToUseDescription": "카드를 클릭해 프롬프트를 복사하세요. 스킬을 설치한 후 Linear에 연결된 워크스페이스에서 사용하세요.", + "terminalTitle": "Linear 스킬 설정", + "terminalAriaLabel": "Linear 스킬 설치 터미널", + "manageConnectionHint": "연결된 Linear 워크스페이스와 API 키는 다음에서 확인할 수 있습니다.", + "manageConnectionLink": "연동" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "개발 플러그인" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. Linear 연결", + "setupConnectBody": "Orca가 이슈를 나열하고 연결된 워크스페이스를 열 수 있도록 하는 개인 API 키입니다.", + "manageKeys": "키 관리", + "addAccess": "액세스 추가", + "setupSkillTitle": "2. 에이전트 스킬 설치", + "setupSkillBody": "에이전트가 /orca-linear로 이슈 읽기, 업데이트, 분류, 풀 또는 머지 리퀘스트 첨부를 할 수 있게 합니다.", + "setupVisibleTitle": "3. 작업에 Linear 표시", + "setupVisibleBody": "작업 소스 선택기와 사이드바 바로 가기에 Linear를 표시합니다.", + "openTaskSources": "작업 소스", + "setupTitle": "설정 체크리스트", + "setupBody": "작업과 에이전트의 전체 흐름에는 세 단계가 모두 필요합니다. 최초 설정은 작업 소스에서도 진행할 수 있습니다.", + "setupReady": "설정 완료", + "setupProgress": "{{total}}개 중 {{done}}개 완료", + "notesTitle": "알아두면 좋은 점", + "notesIntro": "Linear를 연결하고 스킬을 설치한 후 참고할 내용입니다.", + "noteLinkedTitle": "Linear 이슈에서 시작", + "noteLinkedBody": "작업에서 만든 워크스페이스는 이슈가 컨텍스트로 연결되어 있어 이슈 작업이 가장 원활합니다.", + "noteSlashTitle": "/orca-linear 언급", + "noteSlashBody": "채팅에서 /orca-linear를 사용하거나 자연어로 요청하면 해당 턴에 에이전트가 스킬을 불러옵니다.", + "noteKeysTitle": "키는 런타임별로 저장", + "noteKeysBody": "API 키와 워크스페이스는 활성 런타임에 저장됩니다.", + "noteVisibilityTitle": "숨겨도 연결은 유지", + "noteVisibilityBody": "작업 소스에서 Linear를 숨기면 선택기에서만 제거됩니다. 키나 스킬은 삭제되지 않습니다.", + "setupChecking": "확인 중…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "Linear 연결", + "connectDescription": "Orca가 이슈를 탐색하고 이슈 컨텍스트로 워크스페이스를 열 수 있도록 개인 API 키를 추가하세요.", + "manageAccess": "키 관리", + "addAccess": "Linear 액세스 추가", + "connectedHint": "워크스페이스와 키는 활성 런타임에 저장됩니다. 언제든 액세스를 더 추가할 수 있습니다.", + "recheck": "연결 다시 확인", + "skillTitle": "Linear 에이전트 스킬 설치", + "skillDescription": "에이전트가 /orca-linear로 이슈 읽기, 업데이트 게시, 상태 변경, 풀 또는 머지 리퀘스트 첨부를 할 수 있게 합니다.", + "skillBlocked": "먼저 Linear를 연결한 다음 에이전트용 스킬을 설치하세요.", + "skillPanelTitle": "Linear 스킬", + "terminalTitle": "Linear 스킬 설정", + "terminalAriaLabel": "Linear 스킬 설치 터미널", + "showDescription": "작업 소스 선택기와 사이드바 바로 가기에 Linear를 표시합니다." + }, + "TaskSourceProviderCard": { + "statusChecking": "확인 중…", + "statusReady": "준비됨", + "statusConnectRequired": "연결 필요", + "statusSkillRequired": "스킬 필요", + "statusUnavailable": "상태를 확인할 수 없음", + "statusHidden": "작업에서 숨김", + "statusIncomplete": "설정 필요", + "collapseSetup": "{{provider}} 설정 단계 접기", + "expandSetup": "{{provider}} 설정 단계 표시" + }, + "TaskSourceShowInTasksStep": { + "shown": "표시 중", + "show": "표시", + "hide": "숨기기", + "hideProviderAction": "작업에서 {{provider}} 숨기기", + "showProviderAction": "작업에 {{provider}} 표시", + "lastProviderAction": "작업에 표시된 제공업체는 {{provider}}입니다. 제공업체는 하나 이상 표시되어야 합니다.", + "lastProviderHint": "작업에는 제공업체가 하나 이상 표시되어야 합니다.", + "title": "작업에 표시", + "description": "이 제공업체를 작업 소스 선택기와 사이드바 바로 가기에 표시합니다." + }, "RuntimeHostAccessForm": { "getLink": "다른 호스트에서 액세스 링크 가져오기", "stepOpenShare": "설정 → 원격 Orca 서버 → 이 호스트 공유를 엽니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 40b1c6b3cc4..79ac74754d3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -616,7 +616,7 @@ "readTicket": "Read the linked ticket", "postUpdate": "Post a progress update", "moveState": "Move the ticket forward", - "attachPr": "Attach the PR link", + "attachPr": "附加评审链接", "triageFollowups": "Triage and create follow-ups", "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", @@ -624,8 +624,8 @@ "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", "moveStateSummary": "Advance the Linear workflow state as the work progresses.", "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", - "attachPrSummary": "Link the pull request to the Linear issue when you open it.", - "attachPrPrompt": "Use {{value0}} to attach this PR to the linked Linear issue with a PR link.", + "attachPrSummary": "创建拉取或合并请求时,将其关联到 Linear 议题。", + "attachPrPrompt": "使用 {{value0}} 将此拉取或合并请求附加到关联的 Linear 议题。", "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." } @@ -790,9 +790,10 @@ "devSearchKeywordNotification": "notification", "projectHostsSummary": "{{value0}} 个主机", "linearTitle": "Linear", - "linearDescription": "Give agents the skill to read and update your linked Linear tickets.", + "linearDescription": "Linear 在 Orca 中的工作方式、设置清单、智能体技能和提示词示例。", "pluginsTitle": "插件", - "pluginsDescription": "安装和管理实验性 Orca 插件。" + "pluginsDescription": "安装和管理实验性 Orca 插件。", + "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。" }, "useAppMenuPaste": { "pasteTooLarge": "粘贴内容过大。" @@ -5273,7 +5274,9 @@ "copyCommandAria": "复制命令", "runCommandDescription": "按 Enter 运行命令。", "5f818f12ab": "正在准备...", - "4c05b9d7cb": "正在准备设置终端。" + "4c05b9d7cb": "正在准备设置终端。", + "installLabel": "安装", + "updateLabel": "更新" }, "AgentsPane": { "d83834f5e6": "检测已安装的智能体...", @@ -6824,7 +6827,8 @@ "dev": "开发工具", "devDescription": "仅用于调试 UI 状态的开发工具。", "linearTitle": "Linear", - "linearDescription": "赋予智能体读取和更新您关联的 Linear 工单的能力。" + "linearDescription": "Linear 在 Orca 中的工作方式、设置清单、智能体技能和提示词示例。", + "tasksDescription": "连接提供商、安装 Linear 技能并选择要在任务中显示的内容。" }, "SettingsFormControls": { "42a4d15a30": "没有匹配的字体。", @@ -7053,18 +7057,34 @@ "53e9aabfc0": "可用时使用 OpenSSH 多路复用。对于有自定义 SSH 限制的主机,请关闭此项。" }, "TasksPane": { - "3a72b9745e": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", "f71d8a9dd3": "任务提供者", - "71644aba56": "选择要在“任务”页面源选择器和侧边栏快捷键中显示的任务提供商。至少需保留一个可见提供商。", - "93e72ef659": "任务来源", - "8e1305fcc6": "在任务源选择器和侧边栏快捷方式中显示 Jira。", "6b23a34f6d": "Jira", - "e4170c9615": "在任务源选择器和侧边栏快捷方式中显示 Linear。", "09ae2d7c51": "Linear", - "dd67a1b6e1": "在任务源选择器和侧边栏快捷方式中显示 GitLab。", "7c5d7fdc20": "GitLab", - "1db47236cd": "在任务源选择器和侧边栏快捷方式中显示 GitHub。", - "e14063e727": "GitHub" + "e14063e727": "GitHub", + "connectProviderTitle": "连接 {{provider}}", + "connectCodeHostDescription": "在集成中安装并验证 CLI,以便 Orca 加载议题。", + "connectionCheckUnavailable": "Orca 无法检查此连接。请重试,或打开“集成”查看设置详情。", + "retryConnection": "重试", + "openIntegrations": "集成", + "connectInIntegrations": "在集成中设置", + "connectJiraTitle": "连接 Jira", + "connectJiraDescription": "使用 API Token 或 PAT 添加 Jira Cloud 站点或自托管实例。", + "manageJira": "管理密钥", + "addJira": "添加 Jira 访问权限", + "githubDescription": "浏览 GitHub 议题并从中启动工作区。", + "gitlabDescription": "浏览 GitLab 议题并从中启动工作区。", + "linearDescription": "连接 Linear、安装智能体技能并将其显示在任务中。", + "jiraDescription": "连接 Jira Cloud 或自托管 Jira 并将其显示在任务中。", + "setupTitle": "任务管理设置", + "setupDescription": "在一处完成每个提供商的连接和显示设置。Linear 还需要智能体技能,以便智能体读取和更新议题。必须至少保留一个可见提供商。", + "incompleteBannerTitle": "部分可见提供商仍需设置", + "incompleteBannerBody": "隐藏不使用的提供商,或展开卡片并完成其中的步骤。", + "providersDescription": "每张卡片都会引导你完成连接、任务显示设置;Linear 还包括技能设置。", + "integrationsHint": "所有提供商的凭据也可在此管理:", + "integrationsLink": "集成", + "skillHint": "。连接 Linear 后,可在设置 → Linear 中查看使用示例。", + "incompleteBannerBodyWithLinear": "隐藏不使用的提供商,或展开卡片并完成其中的步骤。Linear 需要 API 访问权限、智能体技能以及在任务中显示。" }, "TerminalAppearanceSection": { "a14a427ae4": "窗格分隔线的厚度。", @@ -8709,8 +8729,12 @@ "3d81c26d78": "来源", "cf0e3e0c2f": "提供者", "2ec54bee51": "任务", - "765f0c544d": "选择要在“任务”页面和侧边栏快捷键中显示的任务提供商。", - "5b8e4aace5": "任务提供者" + "5b8e4aace5": "任务提供者", + "providersDescription": "连接任务提供商、安装 Linear 智能体技能并选择要在任务中显示的内容。", + "setup": "设置", + "apiKey": "API 密钥", + "skill": "技能", + "connect": "连接" } }, "terminal": { @@ -9487,13 +9511,13 @@ "notInstalled": "未安装", "recheck": "重新检查", "installedDescription": "智能体技能已安装。要更新,请运行:", - "description": "让您的智能体读取和编辑 Linear 任务。", + "description": "让智能体读取和编辑 Linear 任务。完整引导设置(连接 + 技能 + 可见性)位于设置 → 任务来源。", "copyCommand": "复制命令" } }, "search": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets." + "description": "Linear 技能状态、使用示例和任务来源设置链接。" } } } @@ -9582,15 +9606,14 @@ }, "LinearAgentSkillPane": { "title": "Linear", - "description": "Give agents the skill to read and update your linked Linear tickets.", - "skillTitle": "Linear skill", - "skillDescription": "Enables agents to read linked tickets and post updates to Linear through Orca.", - "howToUse": "How to use it", - "howToUseDescription": "Ask an agent working a Linear-linked worktree to read context, post updates, move the ticket, or attach the PR.", - "terminalTitle": "Linear skill setup", - "terminalAriaLabel": "Linear skill install terminal", - "manageConnectionHint": "Review connected Linear workspaces and API keys in", - "manageConnectionLink": "Integrations settings" + "description": "在 Orca 中浏览 Linear 议题、启动关联工作区,并让智能体通过 /orca-linear 更新议题。", + "skillTitle": "Linear 技能", + "howToUse": "提示词示例", + "howToUseDescription": "点击卡片即可复制提示词。安装技能后,请在关联 Linear 的工作区中使用。", + "terminalTitle": "Linear 技能设置", + "terminalAriaLabel": "Linear 技能安装终端", + "manageConnectionHint": "可在此查看已连接的 Linear 工作区和 API 密钥:", + "manageConnectionLink": "集成" }, "MobileRelayBetaNotice": { "notice": "Orca Relay is in beta." @@ -9907,6 +9930,69 @@ "development": "开发插件" } }, + "LinearAgentSkillGuide": { + "setupConnectTitle": "1. 连接 Linear", + "setupConnectBody": "用于让 Orca 列出议题并打开关联工作区的个人 API 密钥。", + "manageKeys": "管理密钥", + "addAccess": "添加访问权限", + "setupSkillTitle": "2. 安装智能体技能", + "setupSkillBody": "让智能体使用 /orca-linear 读取、更新、分类议题并附加拉取或合并请求。", + "setupVisibleTitle": "3. 在任务中显示 Linear", + "setupVisibleBody": "在任务来源选择器和侧边栏快捷方式中保留 Linear。", + "openTaskSources": "任务来源", + "setupTitle": "设置清单", + "setupBody": "完整的任务与智能体流程需要完成全部三个步骤。首次设置也可在任务来源中进行。", + "setupReady": "全部就绪", + "setupProgress": "已完成 {{done}}/{{total}}", + "notesTitle": "使用须知", + "notesIntro": "连接 Linear 并安装技能后的一些快速提示。", + "noteLinkedTitle": "从 Linear 议题开始", + "noteLinkedBody": "从任务创建的工作区会将议题保留为关联上下文,因此议题操作最顺畅。", + "noteSlashTitle": "提及 /orca-linear", + "noteSlashBody": "在聊天中使用 /orca-linear(或用自然语言提出请求),智能体会在该轮加载此技能。", + "noteKeysTitle": "密钥按运行环境保存", + "noteKeysBody": "API 密钥和工作区存储在当前运行环境中。", + "noteVisibilityTitle": "隐藏不等于断开连接", + "noteVisibilityBody": "在任务来源中隐藏 Linear 只会将其从选择器中移除,不会删除密钥或技能。", + "setupChecking": "检查中…" + }, + "TaskSourceLinearSetup": { + "connectTitle": "连接 Linear", + "connectDescription": "添加个人 API 密钥,让 Orca 能够浏览议题并使用议题上下文打开工作区。", + "manageAccess": "管理密钥", + "addAccess": "添加 Linear 访问权限", + "connectedHint": "工作区和密钥存储在当前运行环境中。你可以随时添加更多访问权限。", + "recheck": "重新检查连接", + "skillTitle": "安装 Linear 智能体技能", + "skillDescription": "让智能体使用 /orca-linear 读取议题、发布更新、更改状态并附加拉取或合并请求。", + "skillBlocked": "请先连接 Linear,然后为智能体安装技能。", + "skillPanelTitle": "Linear 技能", + "terminalTitle": "Linear 技能设置", + "terminalAriaLabel": "Linear 技能安装终端", + "showDescription": "在任务来源选择器和侧边栏快捷方式中显示 Linear。" + }, + "TaskSourceProviderCard": { + "statusChecking": "检查中…", + "statusReady": "已就绪", + "statusConnectRequired": "需要连接", + "statusSkillRequired": "需要技能", + "statusUnavailable": "状态不可用", + "statusHidden": "已从任务中隐藏", + "statusIncomplete": "需要设置", + "collapseSetup": "收起 {{provider}} 设置步骤", + "expandSetup": "显示 {{provider}} 设置步骤" + }, + "TaskSourceShowInTasksStep": { + "shown": "已显示", + "show": "显示", + "hide": "隐藏", + "hideProviderAction": "从任务中隐藏 {{provider}}", + "showProviderAction": "在任务中显示 {{provider}}", + "lastProviderAction": "{{provider}} 已显示在任务中。必须至少保留一个可见提供商。", + "lastProviderHint": "任务中必须至少保留一个可见提供商。", + "title": "在任务中显示", + "description": "在任务来源选择器和侧边栏快捷方式中显示此提供商。" + }, "RuntimeHostAccessForm": { "getLink": "从另一台主机获取访问链接", "stepOpenShare": "打开设置 → 远程 Orca 服务器 → 共享此主机。", diff --git a/src/renderer/src/lib/linear-usage-examples.ts b/src/renderer/src/lib/linear-usage-examples.ts index af6b0a5f1e8..38c60bfc8c4 100644 --- a/src/renderer/src/lib/linear-usage-examples.ts +++ b/src/renderer/src/lib/linear-usage-examples.ts @@ -47,14 +47,14 @@ export const getLinearUsageExamples = createLocalizedCatalog((): SkillUsageExamp }, { id: 'attach-pr', - title: translate('auto.lib.linear.usage.examples.attachPr', 'Attach the PR link'), + title: translate('auto.lib.linear.usage.examples.attachPr', 'Attach the review link'), summary: translate( 'auto.lib.linear.usage.examples.attachPrSummary', - 'Link the pull request to the Linear issue when you open it.' + 'Link the pull or merge request to the Linear issue when you open it.' ), prompt: translate( 'auto.lib.linear.usage.examples.attachPrPrompt', - 'Use {{value0}} to attach this PR to the linked Linear issue with a PR link.', + 'Use {{value0}} to attach this pull or merge request to the linked Linear issue.', { value0: LINEAR_SLASH_COMMAND } ) }, From d73c61364f3a471767331533e5336708526829c6 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 16:42:13 -0700 Subject: [PATCH 011/123] fix(win): harden startup during partial updates (#11613) --- .../electron-vite-output-contract.test.ts | 34 ++++++++++- electron.vite.config.ts | 57 +++++++++++++++---- .../bootstrap-fatal-exit-guard.test.ts | 27 +++++++++ .../startup/bootstrap-fatal-exit-guard.ts | 12 ++++ src/main/startup/main-process-error-guards.ts | 2 + 5 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 src/main/startup/bootstrap-fatal-exit-guard.test.ts create mode 100644 src/main/startup/bootstrap-fatal-exit-guard.ts diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index eed66def4ed..1154ec9cc57 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs' +import { EventEmitter } from 'node:events' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { electronViteConfig } from '../../electron.vite.config' +import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config' +import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') @@ -29,6 +32,35 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + expect(external('zod', undefined, false)).toBe(false) + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') + }) + + it('exits when a static import fails before source error guards load', () => { + const processMock = new EventEmitter() as EventEmitter & { + exit: (code: number) => void + exitCode?: number + } + let scheduledExit: (() => void) | null = null + let exitedWith: number | null = null + processMock.exit = (code) => { + exitedWith = code + } + const context = { + process: processMock, + setImmediate: (callback: () => void) => { + scheduledExit = callback + } + } + + runInNewContext(createBootstrapFatalExitBanner(), context) + processMock.emit('uncaughtException', new Error("Cannot find module 'zod'")) + + expect(processMock.exitCode).toBe(1) + expect(scheduledExit).not.toBeNull() + scheduledExit?.() + expect(exitedWith).toBe(1) + expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY) }) it('isolates renderer entry side effects behind strict facades', () => { diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d2acd8a6c0b..562d6e9db91 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -6,10 +6,17 @@ import tailwindcss from '@tailwindcss/vite' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } -const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize']) +const BUNDLED_MAIN_DEPENDENCIES = new Set([ + '@xterm/headless', + '@xterm/addon-serialize', + // Why: Windows NSIS deploys app.asar before external resources; bootstrap must + // not race the later resources/node_modules copy. + 'zod' +]) const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter( (dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency) ) +const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' function isExternalMainModule(source: string): boolean { if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) { @@ -164,19 +171,47 @@ function createStartupDiagnosticsBanner(chunkName: string): string { ` } -function createStartupDiagnosticsBootstrapPlugin() { +export function createBootstrapFatalExitBanner(): string { + // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. + return ` +;(() => { + const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} + if (typeof globalThis[guardKey] === 'function') { + return + } + let exitScheduled = false + const exitAfterBootstrapFailure = () => { + if (exitScheduled) { + return + } + exitScheduled = true + process.exitCode = 1 + setImmediate(() => process.exit(1)) + } + globalThis[guardKey] = () => { + process.off('uncaughtException', exitAfterBootstrapFailure) + delete globalThis[guardKey] + } + process.once('uncaughtException', exitAfterBootstrapFailure) +})(); +` +} + +function createMainBootstrapPlugin() { return { - name: 'orca-startup-diagnostics-bootstrap', + name: 'orca-main-bootstrap', generateBundle(_options, bundle) { const mainChunk = bundle['index.js'] if (!mainChunk || mainChunk.type !== 'chunk') { return } - // Why: source-level startup diagnostics run after Rollup's generated - // prelude and require() list. Mutate the final emitted chunk so macOS - // launch failures can identify the earliest JS boundary reached. - mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code + // Why: source guards and diagnostics run after Rollup's generated require + // prelude, too late to handle a missing bootstrap dependency. + mainChunk.code = + createBootstrapFatalExitBanner() + + createStartupDiagnosticsBanner(mainChunk.fileName) + + mainChunk.code } } } @@ -186,10 +221,10 @@ export const electronViteConfig: UserConfig = { build: { // Why: daemon-entry.js is asar-unpacked so child_process.fork() can // execute it from disk. Node's module resolution from the unpacked - // directory cannot reach into app.asar, so pure-JS dependencies used - // by the daemon must be bundled rather than externalized. + // directory cannot reach into app.asar; startup-critical pure JS must + // also survive a partially copied Windows resources tree. externalizeDeps: { - exclude: ['@xterm/headless', '@xterm/addon-serialize'] + exclude: [...BUNDLED_MAIN_DEPENDENCIES] }, rollupOptions: { // Why: native dependencies must resolve from packaged node_modules, @@ -243,7 +278,7 @@ export const electronViteConfig: UserConfig = { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' }, - plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] + plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] } }, // Why: compile-time substitution for the telemetry gate. See the block diff --git a/src/main/startup/bootstrap-fatal-exit-guard.test.ts b/src/main/startup/bootstrap-fatal-exit-guard.test.ts new file mode 100644 index 00000000000..cff8b372578 --- /dev/null +++ b/src/main/startup/bootstrap-fatal-exit-guard.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BOOTSTRAP_FATAL_EXIT_GUARD_KEY, + removeBootstrapFatalExitGuard +} from './bootstrap-fatal-exit-guard' + +type BootstrapFatalExitGlobal = typeof globalThis & { + [BOOTSTRAP_FATAL_EXIT_GUARD_KEY]?: () => void +} + +const bootstrapGlobal = globalThis as BootstrapFatalExitGlobal + +afterEach(() => { + delete bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] +}) + +describe('bootstrap fatal exit guard', () => { + it('removes the generated pre-import guard', () => { + const removeGuard = vi.fn() + bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] = removeGuard + + removeBootstrapFatalExitGuard() + + expect(removeGuard).toHaveBeenCalledOnce() + expect(bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY]).toBeUndefined() + }) +}) diff --git a/src/main/startup/bootstrap-fatal-exit-guard.ts b/src/main/startup/bootstrap-fatal-exit-guard.ts new file mode 100644 index 00000000000..89a38317f22 --- /dev/null +++ b/src/main/startup/bootstrap-fatal-exit-guard.ts @@ -0,0 +1,12 @@ +export const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' + +type BootstrapFatalExitGlobal = typeof globalThis & { + [BOOTSTRAP_FATAL_EXIT_GUARD_KEY]?: () => void +} + +export function removeBootstrapFatalExitGuard(): void { + const bootstrapGlobal = globalThis as BootstrapFatalExitGlobal + const removeGuard = bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] + delete bootstrapGlobal[BOOTSTRAP_FATAL_EXIT_GUARD_KEY] + removeGuard?.() +} diff --git a/src/main/startup/main-process-error-guards.ts b/src/main/startup/main-process-error-guards.ts index fcf178c67be..82735361832 100644 --- a/src/main/startup/main-process-error-guards.ts +++ b/src/main/startup/main-process-error-guards.ts @@ -1,4 +1,5 @@ import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { removeBootstrapFatalExitGuard } from './bootstrap-fatal-exit-guard' type FatalMainProcessErrorKind = 'main_uncaught_exception' | 'main_unhandled_rejection' @@ -115,6 +116,7 @@ export function installUncaughtPipeErrorGuard(): void { } process.on('uncaughtException', onUncaughtException) + removeBootstrapFatalExitGuard() } /** Keep one failed background promise from silently killing the whole app. From abec3588e7ad4de9c6cad8f9f12b446abd909193 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:55:18 -0700 Subject: [PATCH 012/123] fix(sidebar): let the scroll anchor follow a re-keyed row (#11543) --- .../src/components/sidebar/WorktreeList.tsx | 31 +-- .../worktree-list-virtual-rows.test.ts | 87 ++++++ .../sidebar/worktree-list-virtual-rows.ts | 59 +++- ...tualizedScrollAnchor.listener-deps.test.ts | 254 ++++++++++++++++++ .../src/hooks/useVirtualizedScrollAnchor.ts | 7 + .../virtualized-scroll-anchor-restore.ts | 30 ++- 6 files changed, 438 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3d315cb616f..8cc18bb43f3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -95,9 +95,11 @@ import { type PinnedWorktreeDisplayPolicy } from './worktree-list-groups' import { + buildLineageRowRekeyMap, estimateRenderRowSize, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, + getRenderRowKey, getStickyHeaderIndexes, getVirtualRowTransform, pruneStaleVirtualRowElementCache, @@ -1210,30 +1212,9 @@ function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { return renderRows } -export function getRenderRowKey(row: RenderRow): string { - if (row.type === 'host-header') { - return `host:${row.hostId}` - } - if (row.type === 'header') { - return `hdr:${row.key}` - } - if (row.type === 'lineage-group') { - return `lineage-group:${row.key}` - } - if (row.type === 'imported-worktrees-card') { - return `imported:${row.key}` - } - if (row.type === 'new-external-worktrees-inbox') { - return `inbox:${row.key}` - } - if (row.type === 'pending-creation') { - return `pending:${row.creationId}` - } - if (row.type === 'folder-workspace') { - return `folder-workspace:${row.folderWorkspace.id}` - } - return `wt:${row.rowKey}` -} +// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the +// long-standing import path working for callers that reach for it here. +export { getRenderRowKey } export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] @@ -2432,6 +2413,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [renderRows] ) const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) + const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -2489,6 +2471,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp getItemElementKey: getVirtualRowKey, getRowKey: getRenderRowKey, itemElementSelector: '[data-worktree-virtual-row]', + rekeyedRowKeys: lineageRowRekeys, rows: renderRows, scrollElementRef: scrollRef, scrollOffsetRef, diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index 6544948c02c..04d2857ed64 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' import { HOST_STICKY_PINNED_HEIGHT, + buildLineageRowRekeyMap, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, + getRenderRowKey, getStickyHeaderIndexes, pruneStaleVirtualRowElementCache, type RenderRow @@ -185,6 +187,91 @@ describe('extractWorktreeVirtualRowIndexes', () => { }) }) +describe('buildLineageRowRekeyMap', () => { + // Mirrors buildWorktreeRow: rowKey is `${sectionKey}:${worktree.id}`. + function worktreeRow(sectionKey: string, worktreeId: string): RenderRow { + return { + type: 'item', + rowKey: `${sectionKey}:${worktreeId}`, + sectionKey, + worktree: { id: worktreeId }, + depth: 0, + groupDepth: 0, + lineageTrail: [], + isLastLineageChild: true, + lineageChildCount: 0 + } as unknown as RenderRow + } + + function lineageGroupRow(sectionKey: string, parentId: string, childIds: string[]): RenderRow { + return { + type: 'lineage-group', + key: `${sectionKey}:lineage:${parentId}`, + rows: [parentId, ...childIds].map( + (id) => worktreeRow(sectionKey, id) as Extract + ) + } + } + + it('folds every lineage-group member onto the group key', () => { + const group = lineageGroupRow('all', 'p', ['c1', 'c2']) + const rekeys = buildLineageRowRekeyMap([group]) + + // The parent's own row key is what an anchor recorded before the child + // existed; all members now live inside the single group row. + expect(rekeys.get('wt:all:p')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:all:c1')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:all:c2')).toBe('lineage-group:all:lineage:p') + expect(getRenderRowKey(group)).toBe('lineage-group:all:lineage:p') + }) + + it('dissolves a group key back onto the plain item row', () => { + // Last child deleted: lineageChildCount is already 0, but an anchor still + // holds the group key, so the reverse mapping must be unguarded. + const rekeys = buildLineageRowRekeyMap([worktreeRow('all', 'p')]) + + expect(rekeys.get('lineage-group:all:lineage:p')).toBe('wt:all:p') + }) + + it('round-trips the fold and dissolve directions for the same worktree', () => { + const folded = buildLineageRowRekeyMap([lineageGroupRow('all', 'p', ['c1'])]) + const dissolved = buildLineageRowRekeyMap([worktreeRow('all', 'p')]) + + expect(dissolved.get(folded.get('wt:all:p') as string)).toBe('wt:all:p') + }) + + it('keeps the same worktree distinct across sections', () => { + // The same worktree renders in both Pinned and All; rowKey embeds the + // section so a pinned anchor must never follow the All copy. + const rekeys = buildLineageRowRekeyMap([ + lineageGroupRow('pinned', 'p', ['c1']), + lineageGroupRow('all', 'p', ['c1']) + ]) + + expect(rekeys.get('wt:pinned:p')).toBe('lineage-group:pinned:lineage:p') + expect(rekeys.get('wt:all:p')).toBe('lineage-group:all:lineage:p') + expect(rekeys.get('wt:pinned:c1')).toBe('lineage-group:pinned:lineage:p') + expect(rekeys.get('wt:all:c1')).toBe('lineage-group:all:lineage:p') + + const dissolvedRekeys = buildLineageRowRekeyMap([ + worktreeRow('pinned', 'p'), + worktreeRow('all', 'p') + ]) + expect(dissolvedRekeys.get('lineage-group:pinned:lineage:p')).toBe('wt:pinned:p') + expect(dissolvedRekeys.get('lineage-group:all:lineage:p')).toBe('wt:all:p') + }) + + it('contributes nothing for non-lineage row types', () => { + expect( + buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size + ).toBe(0) + }) + + it('is empty for an empty row list', () => { + expect(buildLineageRowRekeyMap([]).size).toBe(0) + }) +}) + describe('pruneStaleVirtualRowElementCache', () => { it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { const activeElement = { diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 8d38d95935f..1ec2e07a8a1 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -1,7 +1,7 @@ import { defaultRangeExtractor } from '@tanstack/react-virtual' import type { Range, VirtualItem } from '@tanstack/react-virtual' import type { HostSectionRow } from './host-section-rows' -import { PINNED_GROUP_KEY } from './worktree-list-groups' +import { getLineageGroupKey, PINNED_GROUP_KEY } from './worktree-list-groups' export const GROUP_HEADER_ROW_HEIGHT = 28 export const HOST_HEADER_ROW_HEIGHT = 32 @@ -15,6 +15,63 @@ export type RenderRow = | HostSectionRow | { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] } +export function getRenderRowKey(row: RenderRow): string { + if (row.type === 'host-header') { + return `host:${row.hostId}` + } + if (row.type === 'header') { + return `hdr:${row.key}` + } + if (row.type === 'lineage-group') { + return `lineage-group:${row.key}` + } + if (row.type === 'imported-worktrees-card') { + return `imported:${row.key}` + } + if (row.type === 'new-external-worktrees-inbox') { + return `inbox:${row.key}` + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'folder-workspace') { + return `folder-workspace:${row.folderWorkspace.id}` + } + return `wt:${row.rowKey}` +} + +/** + * Old row key -> current row key for rows that were re-keyed without moving. + * + * A parent's key flips between `wt:` and `lineage-group:` the moment it gains + * its first child (and back when it loses its last), even though the row still + * starts at the same pixel. Without this, a scroll anchor recorded under the + * old key resolves a fallback row below it and the sidebar visibly jumps. + */ +export function buildLineageRowRekeyMap(rows: readonly RenderRow[]): ReadonlyMap { + const rekeyed = new Map() + for (const row of rows) { + if (row.type === 'lineage-group') { + const groupKey = getRenderRowKey(row) + for (const member of row.rows) { + rekeyed.set(`wt:${member.rowKey}`, groupKey) + } + continue + } + if (row.type !== 'item') { + continue + } + // Why: deliberately unguarded by lineageChildCount — the dissolve case (last + // child deleted) is exactly when the count is already 0 but an anchor still + // holds the group key. + rekeyed.set( + `lineage-group:${row.sectionKey}:${getLineageGroupKey(row.worktree.id)}`, + getRenderRowKey(row) + ) + } + return rekeyed +} + export function shouldUseHeaderTopSpacing(args: { rows: readonly RenderRow[] index: number diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts index bb8755760ac..1c48d6456ff 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { runVirtualizedScrollAnchorRestore } from './virtualized-scroll-anchor-restore' function createReactHookHarness() { const refs: { current: unknown }[] = [] @@ -171,3 +172,256 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { expect(anchorRef.current).toBe(savedAnchor) }) }) + +// Sidebar geometry from the reported jump: 12 rows x 100px in a 300px viewport, +// scrolled to 520 so the parent worktree ("p") sits 20px above the top edge. +type FakeRow = { key: string; size: number } +const VIEWPORT_HEIGHT = 300 +const ITEM_SELECTOR = '[data-worktree-virtual-row]' +const PARENT_KEY = 'wt:sec:p' +const GROUP_KEY = 'lineage-group:sec:lineage:p' + +function row(key: string, size = 100): FakeRow { + return { key, size } +} + +const LEADING_ROWS = ['r0', 'r1', 'r2', 'r3', 'r4'].map((id) => row(`wt:sec:${id}`)) +const TRAILING_ROWS = ['s0', 's1', 's2', 'r10', 'r11'].map((id) => row(`wt:sec:${id}`)) +// Before the child exists: parent and its (future) child are separate rows. +const UNFOLDED_ROWS = [...LEADING_ROWS, row(PARENT_KEY), row('wt:sec:c'), ...TRAILING_ROWS] +// After the child is created: both fold into one 200px lineage-group row that +// still starts at 500px, so nothing on screen actually moved. +const FOLDED_ROWS = [...LEADING_ROWS, row(GROUP_KEY, 200), ...TRAILING_ROWS] +// Parent genuinely deleted: the rows below slide up by 200px. +const DELETED_ROWS = [...LEADING_ROWS, ...TRAILING_ROWS] +// Last child deleted: the 200px group collapses back to a 100px card. +const DISSOLVED_ROWS = [...LEADING_ROWS, row(PARENT_KEY), ...TRAILING_ROWS] + +function createFakeScroller(rows: readonly FakeRow[], scrollTop: number) { + const starts: number[] = [] + let total = 0 + for (const item of rows) { + starts.push(total) + total += item.size + } + + const el = { + clientHeight: VIEWPORT_HEIGHT, + scrollHeight: total, + scrollTop, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + getBoundingClientRect: () => ({ top: 0, bottom: VIEWPORT_HEIGHT, height: VIEWPORT_HEIGHT }), + querySelectorAll: () => elements + } + const elements = rows.map((item, index) => ({ + isConnected: true, + rowKey: item.key, + getBoundingClientRect: () => { + const top = (starts[index] as number) - el.scrollTop + return { top, bottom: top + item.size, height: item.size } + } + })) + + return { + el, + elements, + rowIndexByKey: new Map(rows.map((item, index) => [item.key, index])), + virtualizer: { + getVirtualItems: () => + rows.map((item, index) => ({ + index, + size: item.size, + start: starts[index] as number, + end: (starts[index] as number) + item.size + })), + isScrolling: false, + scrollToIndex: vi.fn() + } + } +} + +function restore(args: { + anchor: { key: string; offset: number; fallbackKeys?: string[]; scrollTop?: number } + rekeyedRowKeys?: ReadonlyMap + rows: readonly FakeRow[] + scrollTop: number + useDom: boolean +}): number { + const scroller = createFakeScroller(args.rows, args.scrollTop) + runVirtualizedScrollAnchorRestore({ + anchor: args.anchor, + el: scroller.el, + ...(args.useDom + ? { + getItemElementKey: (element: { rowKey: string }) => element.rowKey, + itemElementSelector: ITEM_SELECTOR + } + : {}), + pendingRestoreRef: { current: false }, + recordScrollAnchor: vi.fn(), + ...(args.rekeyedRowKeys ? { rekeyedRowKeys: args.rekeyedRowKeys } : {}), + rowIndexByKey: scroller.rowIndexByKey, + scrollOffsetRef: { current: args.scrollTop }, + virtualizer: scroller.virtualizer + } as never) + return scroller.el.scrollTop +} + +// What WorktreeList's buildLineageRowRekeyMap produces for FOLDED_ROWS. +const FOLD_REKEYS: ReadonlyMap = new Map([ + [PARENT_KEY, GROUP_KEY], + ['wt:sec:c', GROUP_KEY] +]) + +const ANCHORED_ON_PARENT = { + key: PARENT_KEY, + offset: 20, + fallbackKeys: ['wt:sec:s0', 'wt:sec:s1', 'wt:sec:s2'], + scrollTop: 520 +} + +describe('runVirtualizedScrollAnchorRestore lineage re-keying', () => { + it.each([ + ['dom', true], + ['measured', false] + ])( + 'keeps the sidebar still (%s path) when the anchored parent folds into a lineage group', + (_label, useDom) => { + // The anchored row did not move — only its key changed from `wt:` to + // `lineage-group:`. Resolving a fallback here jumped the list +180px. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: FOLD_REKEYS, + rows: FOLDED_ROWS, + scrollTop: 520, + useDom + }) + ).toBe(520) + } + ) + + it.each([ + ['dom', true], + ['measured', false] + ])( + 'still pins a genuine fallback at offset 0 (%s path) when no rekey map is supplied', + (_label, useDom) => { + // CombinedDiffViewer never passes a map; its behaviour must not change. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rows: FOLDED_ROWS, + scrollTop: 520, + useDom + }) + ).toBe(700) + } + ) + + it('pins a fallback at offset 0 when the anchored row was genuinely deleted', () => { + // A rekey map is supplied but has no entry for the removed row, so the + // fallback path (different row, offset dropped) must still win. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: new Map(), + rows: DELETED_ROWS, + scrollTop: 520, + useDom: true + }) + ).toBe(500) + }) + + it.each([ + ['dom', true], + ['measured', false] + ])( + 'clamps the followed offset to the shorter row (%s path) when a group dissolves', + (_label, useDom) => { + // Anchor recorded 150px into the 200px group row; the dissolved card is + // only 100px tall, so the offset clamps the way recording does. + expect( + restore({ + anchor: { key: GROUP_KEY, offset: 150, fallbackKeys: ['wt:sec:s0'], scrollTop: 650 }, + rekeyedRowKeys: new Map([[GROUP_KEY, PARENT_KEY]]), + rows: DISSOLVED_ROWS, + scrollTop: 650, + useDom + }) + ).toBe(600) + } + ) + + it('ignores a rekey target that is not rendered this pass', () => { + // Stale map entry pointing at an absent row must degrade to the fallback, + // not leave the anchor unresolved. + expect( + restore({ + anchor: { ...ANCHORED_ON_PARENT }, + rekeyedRowKeys: new Map([[PARENT_KEY, 'lineage-group:sec:lineage:gone']]), + rows: DELETED_ROWS, + scrollTop: 520, + useDom: true + }) + ).toBe(500) + }) +}) + +describe('useVirtualizedScrollAnchor with sidebar row re-keying', () => { + afterEach(() => { + vi.doUnmock('react') + vi.resetModules() + }) + + it('does not scroll when a child worktree folds the anchored parent into a group', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor, VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } = + await import('./useVirtualizedScrollAnchor') + + const anchorRef: { current: unknown } = { current: null } + const scrollOffsetRef = { current: 520 } + let scroller = createFakeScroller(UNFOLDED_ROWS, 520) + const scrollElementRef: { current: unknown } = { current: scroller.el } + const listeners = new Map void>() + scroller.el.addEventListener.mockImplementation((name: string, handler: () => void) => { + listeners.set(name, handler) + }) + + // Sidebar's real config: DOM-confirmed rows, no restoreSignal, no marks. + const render = (rows: readonly FakeRow[], rekeyedRowKeys?: ReadonlyMap) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getItemElementKey: (element: { rowKey: string }) => element.rowKey, + getRowKey: (item: FakeRow) => item.key, + itemElementSelector: ITEM_SELECTOR, + rekeyedRowKeys, + rows, + scrollElementRef, + scrollOffsetRef, + totalSize: rows.reduce((sum, item) => sum + item.size, 0), + virtualizer: scroller.virtualizer + } as never) + } + + render(UNFOLDED_ROWS) + harness.effects[0]?.effect() + listeners.get(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)?.() + expect(anchorRef.current).toMatchObject({ key: PARENT_KEY, offset: 20, scrollTop: 520 }) + + // Child created: same pixels, new key for the parent's row. + scroller = createFakeScroller(FOLDED_ROWS, 520) + scrollElementRef.current = scroller.el + render(FOLDED_ROWS, FOLD_REKEYS) + harness.effects[1]?.effect() + + expect(scroller.el.scrollTop).toBe(520) + // The anchor re-records against the row's new key so later restores hit the + // exact-key path. + expect(anchorRef.current).toMatchObject({ key: GROUP_KEY, offset: 20 }) + }) +}) diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts index 56353fbee43..6594aedd0e3 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts @@ -46,6 +46,10 @@ type UseVirtualizedScrollAnchorOptions< // Why: some callers record user scroll anchors outside this hook; passive // programmatic scroll events during remount must not teach them a transient row. recordAnchorOnScroll?: boolean + // Why: old key -> new key for rows this render re-keyed without moving, so an + // anchor recorded under the old key follows its own row instead of pinning a + // neighbour. Omitted by callers whose row keys are stable. + rekeyedRowKeys?: ReadonlyMap // Why: when provided, anchor restore runs only when this value changes // (structural row changes) or while a prior restore is still converging — // not on every totalSize/isScrolling tick. Measurement-driven shifts are the @@ -80,6 +84,7 @@ export function useVirtualizedScrollAnchor< programmaticScrollMarks, recordAnchorOnCleanup = true, recordAnchorOnScroll = true, + rekeyedRowKeys, restoreSignal, rows, scrollElementRef, @@ -291,6 +296,7 @@ export function useVirtualizedScrollAnchor< pendingRestoreRef, programmaticScrollMarks: programmaticScrollMarksRef.current, recordScrollAnchor, + rekeyedRowKeys, rowIndexByKey, scrollOffsetRef, virtualizer @@ -300,6 +306,7 @@ export function useVirtualizedScrollAnchor< getItemElementKey, itemElementSelector, recordScrollAnchor, + rekeyedRowKeys, restoreSignal, rowIndexByKey, scrollElementRef, diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts index 083e95acb13..a3cba0c8694 100644 --- a/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-restore.ts @@ -17,6 +17,9 @@ type RunVirtualizedScrollAnchorRestoreArgs< pendingRestoreRef: MutableRefObject programmaticScrollMarks?: ProgrammaticScrollMarks recordScrollAnchor: (scrollTop: number) => void + // Why: old key -> new key for rows that were re-keyed without moving. Lets an + // anchor follow its own row instead of resolving a fallback row below it. + rekeyedRowKeys?: ReadonlyMap rowIndexByKey: ReadonlyMap scrollOffsetRef: MutableRefObject virtualizer: Virtualizer @@ -40,13 +43,18 @@ export function runVirtualizedScrollAnchorRestore< pendingRestoreRef, programmaticScrollMarks, recordScrollAnchor, + rekeyedRowKeys, rowIndexByKey, scrollOffsetRef, virtualizer }: RunVirtualizedScrollAnchorRestoreArgs): (() => void) | undefined { - const resolvedKey = rowIndexByKey.has(anchor.key) + const hasExactKey = rowIndexByKey.has(anchor.key) + const rekeyed = hasExactKey ? undefined : rekeyedRowKeys?.get(anchor.key) + // Why: only follow a rekey that names a row present in this render. + const followedKey = rekeyed !== undefined && rowIndexByKey.has(rekeyed) ? rekeyed : undefined + const resolvedKey = hasExactKey ? anchor.key - : anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key)) + : (followedKey ?? anchor.fallbackKeys?.find((key) => rowIndexByKey.has(key))) if (!resolvedKey) { // Why: an unresolvable anchor has nothing to converge; rows that make it // resolvable again always arrive via a signal change, so leaving the arm @@ -59,7 +67,16 @@ export function runVirtualizedScrollAnchorRestore< pendingRestoreRef.current = false return } - const offset = resolvedKey === anchor.key ? anchor.offset : 0 + // Why: a fallback key means the anchored row was REMOVED and the row below + // slid up — a different row, so the within-row offset does not transfer. A + // followed key is the SAME row under a new key, so it does. + const followedRow = resolvedKey === followedKey + const offset = hasExactKey || followedRow ? anchor.offset : 0 + // Why: the followed row can be a different height than the one the offset was + // recorded against (a lineage group dissolving back to a single card), so + // clamp it the same way recording does. + const offsetWithinRow = (rowHeight: number): number => + followedRow ? Math.min(rowHeight, offset) : offset const canConfirmFromDom = Boolean(itemElementSelector && getItemElementKey) const restoreFromDomElement = (): boolean => { @@ -75,7 +92,7 @@ export function runVirtualizedScrollAnchorRestore< } const scrollRect = el.getBoundingClientRect() const rect = element.getBoundingClientRect() - const desiredTop = scrollRect.top - offset + const desiredTop = scrollRect.top - offsetWithinRow(rect.height) const delta = rect.top - desiredTop if (Math.abs(delta) > 1) { // Why: this scroll write is still part of restore; keep the target @@ -99,7 +116,10 @@ export function runVirtualizedScrollAnchorRestore< return false } const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight) - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, item.start + offset)) + const nextScrollTop = Math.min( + maxScrollTop, + Math.max(0, item.start + offsetWithinRow(item.size)) + ) if (Math.abs(el.scrollTop - nextScrollTop) > 1) { programmaticScrollMarks?.mark(nextScrollTop) el.scrollTop = nextScrollTop From 90692ba40b7d4b8c8311d103a31206eb811a9593 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:07:40 -0700 Subject: [PATCH 013/123] fix(daemon): preserve audit evidence polarity (#11626) --- src/main/daemon/daemon-audit-classifier.ts | 23 ++-- .../daemon-incarnation-evidence.test.ts | 42 +++++++ .../daemon/daemon-incarnation-evidence.ts | 5 +- src/main/daemon/daemon-pty-adapter.test.ts | 49 +++++++- src/main/daemon/daemon-pty-adapter.ts | 105 ++++++++++++++---- src/shared/daemon-audit-eligibility.ts | 2 - 6 files changed, 189 insertions(+), 37 deletions(-) diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts index b95408f1415..4642acb7191 100644 --- a/src/main/daemon/daemon-audit-classifier.ts +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -123,14 +123,14 @@ export async function classifyDaemonAuditFailure( context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [], options.additionalEvidenceSources ?? [] ) - if ( - options.endpointGoneProof && - context.endpointKind === 'windows-named-pipe' && - exactIncarnation - ) { + const windowsNamedPipeMissing = + options.endpointGoneProof === 'windows_named_pipe_missing' && + context.endpointKind === 'windows-named-pipe' + const observedEndpointState = windowsNamedPipeMissing ? 'missing' : endpointState + if (windowsNamedPipeMissing && exactIncarnation && processEvidence.state !== 'present') { return { state: 'gone', - reason: options.endpointGoneProof, + reason: 'windows_named_pipe_missing', trigger, evidenceSources, context, @@ -138,8 +138,8 @@ export async function classifyDaemonAuditFailure( reachability, inventoryAuthority: 'unavailable', processLiveness: 'gone', - processReason: options.endpointGoneProof, - endpointState: 'missing', + processReason: 'windows_named_pipe_missing', + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -155,7 +155,7 @@ export async function classifyDaemonAuditFailure( inventoryAuthority: 'unavailable', processLiveness: 'gone', processReason: processEvidence.reason, - endpointState, + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -170,7 +170,7 @@ export async function classifyDaemonAuditFailure( inventoryAuthority: 'unavailable', processLiveness: processEvidence.state, processReason: processEvidence.reason, - endpointState, + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -192,9 +192,6 @@ async function inspectDaemonEndpointState( function reachabilityForTrigger( trigger: DaemonAuditFailureTrigger ): 'authenticated' | 'disconnected' | 'unknown' { - if (trigger === 'endpoint_identity_changed') { - return 'authenticated' - } if ( trigger === 'transport_closed' || trigger === 'token_missing_after_authenticated_disconnect' diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts index 0a319a346d7..73e4a337e3c 100644 --- a/src/main/daemon/daemon-incarnation-evidence.test.ts +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -114,6 +114,18 @@ describe('daemon process identity evidence', () => { ).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' }) }) + it('keeps a missing proc stat unknown when signal zero still sees the pid', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'missing' }) + }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' }) + }) + it('keeps an unreadable command line unknown even when Linux start identity matches', async () => { await expect( probeDaemonProcessIdentity( @@ -324,6 +336,36 @@ describe('daemon audit availability evidence', () => { }) }) + it('keeps contradictory Windows pipe and process evidence unknown', async () => { + const windowsContext: DaemonAuditContext = { + ...context, + endpoint: '\\\\?\\pipe\\orca-daemon', + endpointKind: 'windows-named-pipe' + } + const dependencies = { + ...auditClassifierDependencies, + probeProcessIdentity: async () => ({ + state: 'present', + reason: 'windows_identity_match', + evidenceSources: ['windows_cim', 'endpoint_identity'] + }) + } satisfies DaemonAuditClassifierDependencies + + await expect( + classifyDaemonAuditFailure(windowsContext, 'inventory_failed', exactIncarnation, { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies + }) + ).resolves.toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'present', + processReason: 'windows_identity_match', + endpointState: 'missing' + }) + }) + it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => { const observation = await classifyDaemonAuditFailure( context, diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 314254cd539..b5c5429a9f2 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -66,7 +66,10 @@ async function probeLinuxProcess( ): Promise { const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid) if (stat.status === 'missing') { - return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation) + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'linux_proc_stat', + 'process_signal' + ]) } if (stat.status === 'unavailable') { return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 69ed1bdbf75..5a3d766b85d 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -19,7 +19,7 @@ import type { SubprocessHandle } from './session' import type { PendingOutputRecord } from './types' import type { DaemonFileLog } from './daemon-file-log' import type * as DaemonHealthModule from './daemon-health' -import { getDaemonSocketPath } from './daemon-spawner' +import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' @@ -1456,6 +1456,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('loads persisted Linux birth identity for audit observations', async () => { + adapter.dispose() + await server.shutdown() + const startedAtMs = 1_700_000_000_000 + const launchNonce = 'launch-with-linux-identity' + server = new DaemonServer({ + socketPath, + tokenPath, + startedAtMs, + launchNonce, + log: daemonLog, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + const pidPath = join(dir, 'daemon.pid') + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs, + launchNonce, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, pidPath }) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(adapter.getLastAuditObservation()?.exactIncarnation).toMatchObject({ + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('reports the daemon session WSL owner', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c5cc304bd10..9fcf5a8633d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,6 +1,7 @@ /* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ import { basename } from 'node:path' import { existsSync, readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth, @@ -152,6 +153,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private socketPath: string private tokenPath: string private pidPath: string | null + private pidRecord: ParsedDaemonPid | null private client: DaemonClient private auditContext: DaemonAuditContext private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null @@ -248,6 +250,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) this.auditContext = { protocolGeneration: this.protocolVersion, provider: 'local-daemon', @@ -1635,34 +1638,61 @@ export class DaemonPtyAdapter implements IPtyProvider { if (previous && sameEndpointIdentity(previous, current)) { return } - const previousExactIncarnation = this.exactDaemonIncarnation - const pidRecord = this.readMatchingPidRecord(current) this.lastAuthenticatedIdentity = { ...current } - this.exactDaemonIncarnation = { - identity: { ...current }, - ...(pidRecord?.linuxStartTicks && pidRecord.bootId - ? { - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : {}) - } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) if (!previous) { return } const event = { previous: { ...previous }, current: { ...current } } notifyAuditListeners(this.identityChangeListeners, event) - this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [ - 'endpoint_identity' - ]) } - private readMatchingPidRecord(identity: DaemonEndpointIdentity): ParsedDaemonPid | null { + private async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + private async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { if (!this.pidPath) { return null } try { - const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8')) + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) return parsed?.pid === identity.pid && parsed.startedAtMs === identity.startedAtMs && parsed.launchNonce === identity.launchNonce @@ -1679,10 +1709,14 @@ export class DaemonPtyAdapter implements IPtyProvider { additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], endpointGoneProof?: 'windows_named_pipe_missing' ): void { - void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, { - additionalEvidenceSources, - endpointGoneProof - }) + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) .then((observation) => this.publishAuditObservation(observation)) .catch(() => {}) } @@ -2303,6 +2337,37 @@ function sameEndpointIdentity( ) } +function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} + function removeListener(listeners: T[], listener: T): void { const index = listeners.indexOf(listener) if (index !== -1) { diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts index 047f95e0665..5e3bde6fcd0 100644 --- a/src/shared/daemon-audit-eligibility.ts +++ b/src/shared/daemon-audit-eligibility.ts @@ -1,7 +1,6 @@ export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const export const DAEMON_AUDIT_TRIGGER_VALUES = [ - 'endpoint_identity_changed', 'inventory_answered', 'inventory_failed', 'token_missing_after_authenticated_disconnect', @@ -57,7 +56,6 @@ export const DAEMON_AUDIT_GONE_REASON_VALUES = [ export const DAEMON_AUDIT_REASON_VALUES = [ 'authenticated_inventory', - 'endpoint_identity_changed', 'inventory_failed', 'token_missing_after_authenticated_disconnect', 'transport_closed', From 7ad7f6a6477f9af5ee15827943980d68e287937f Mon Sep 17 00:00:00 2001 From: TaeHwan Jung Date: Fri, 31 Jul 2026 09:16:41 +0900 Subject: [PATCH 014/123] fix(editor): drop the unreachable uppercase .R extension key (#11322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detectLanguage lowercases the extension before the table lookup, so the '.R' entry can never be reached — '.r' already covers every casing. It is also the only key in the table with an uppercase character, and leaving it suggests uppercase extensions need their own rows. --- src/renderer/src/lib/language-detect.test.ts | 5 +++++ src/renderer/src/lib/language-detect.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/language-detect.test.ts b/src/renderer/src/lib/language-detect.test.ts index 8fd253c977a..77f5331e233 100644 --- a/src/renderer/src/lib/language-detect.test.ts +++ b/src/renderer/src/lib/language-detect.test.ts @@ -59,6 +59,11 @@ describe('detectLanguage', () => { expect(detectLanguage('scripts/legacy.cjs')).toBe('javascript') }) + it('maps .r files to the r language id regardless of case', () => { + expect(detectLanguage('analysis/model.r')).toBe('r') + expect(detectLanguage('analysis/MODEL.R')).toBe('r') + }) + it('keeps .json/.jsonc on the built-in json language and unknown on plaintext', () => { expect(detectLanguage('config/settings.json')).toBe('json') expect(detectLanguage('config/tsconfig.jsonc')).toBe('json') diff --git a/src/renderer/src/lib/language-detect.ts b/src/renderer/src/lib/language-detect.ts index fc918dd4323..9be15124ada 100644 --- a/src/renderer/src/lib/language-detect.ts +++ b/src/renderer/src/lib/language-detect.ts @@ -74,7 +74,6 @@ const EXT_TO_LANGUAGE: Record = { '.proto': 'proto', '.lua': 'lua', '.r': 'r', - '.R': 'r', '.scala': 'scala', '.dart': 'dart', '.ex': 'elixir', From 5cc502cc553f508be3afe5e52d8da0a049503f2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:23:01 -0700 Subject: [PATCH 015/123] fix(mobile): keep terminal input composable while the connection is cut (#11463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep terminal input composable while the connection is cut Fixes #6713. While the socket was down every input control on the mobile session screen was hard-disabled by the single canSend gate — the keyboard would not even open, and everything typed during the outage was silently discarded. Split the gate: canCompose (local composing, survives an outage) vs canSend (needs the live socket). The buffered command box stays editable offline and holds the text; the send button, accessory keys, and live-input capture stay connection-gated; the live/buffered mode toggle stays tappable so live-mode users can reach the compose box. The return-key submit path holds composed text instead of firing a doomed RPC. Also reset the live-input mirror when the connection drops: bytes sent into a stalled link are lost but were recorded as delivered, so the first post-reconnect send replayed stale fragments or emitted phantom erases (observed as `YZZYecho CLEANLINE` corrupting the next command on device). * fix(mobile): stop stalled terminal input replaying into the PTY after reconnect Device verification of the first commit surfaced the real replay vector for the second defect: sendRequest parks in waitForConnected while disconnected, so live-mirror deltas queued behind a dying send drain into the connect wait and fire on the next socket — bytes typed during an outage executed tens of seconds later (observed on device as the prompt reading `nOPQ` after reconnect with no post-recovery typing). Add SendRequestOptions.failWhenDisconnected — reject now instead of parking — and opt in every keystroke-grade terminal send: live mirror, accessory keys, buffered command send, and gesture arrows. Deliberate command sends (initialPrompt on terminal create) keep the connect wait. terminal.send param construction moves to terminal-send-request.ts and the accessory raw-send tail to terminal-live-accessory-raw-send.ts. Re-verified on simulator through a blackhole cut-proxy: text typed during the stall no longer replays, and the first post-recovery command executes verbatim. * test(mobile): assert route-slice anchors are unique so pins cannot slice the wrong region * docs(mobile): trim replay-fix comments to one-line rationale --- .../app/h/[hostId]/session/[worktreeId].tsx | 134 ++++++++-------- .../terminal-input-connection-gate.test.ts | 136 +++++++++++++++++ .../terminal-input-connection-gate.ts | 26 ++++ .../terminal-live-accessory-raw-send.test.ts | 56 +++++++ .../terminal-live-accessory-raw-send.ts | 43 ++++++ mobile/src/terminal/terminal-send-request.ts | 26 ++++ .../use-terminal-live-input-commit.test.ts | 55 ++++++- .../use-terminal-live-input-commit.ts | 9 ++ .../rpc-client-connect-wait-replay.test.ts | 143 ++++++++++++++++++ mobile/src/transport/rpc-client.ts | 6 + 10 files changed, 566 insertions(+), 68 deletions(-) create mode 100644 mobile/src/terminal/terminal-input-connection-gate.test.ts create mode 100644 mobile/src/terminal/terminal-input-connection-gate.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send.test.ts create mode 100644 mobile/src/terminal/terminal-live-accessory-raw-send.ts create mode 100644 mobile/src/terminal/terminal-send-request.ts create mode 100644 mobile/src/transport/rpc-client-connect-wait-replay.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 9deac34cd36..6a7bfc3a54f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -114,7 +114,7 @@ import { loadTerminalAccessoryLayout } from '../../../../src/terminal/terminal-accessory-layout' import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' -import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' +import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, focusTerminalLiveInputTarget, @@ -127,6 +127,11 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' +import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate' +import { + buildTerminalSendParams, + TERMINAL_INPUT_SEND_OPTIONS +} from '../../../../src/terminal/terminal-send-request' import { getTerminalCommandKeyboardType, getTerminalLiveInputKeyboardType @@ -1064,18 +1069,18 @@ export default function SessionScreen() { activeHandleRef, activeSessionTabType: activeSessionTab?.type, activeSessionTabTypeRef, + connected: connState === 'connected', liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, sendLiveTerminalInputRef, setLiveInputCapture }) - const canSend = - connState === 'connected' && - activeHandle != null && - activeSessionTab?.type !== 'markdown' && - activeSessionTab?.type !== 'file' && - activeSessionTab?.type !== 'browser' + const { canCompose, canSend } = resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType: activeSessionTab?.type + }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. @@ -2998,7 +3003,8 @@ export default function SessionScreen() { }, [activeSessionTab, fileDocs, readFileTab]) async function handleSend() { - if (!client || !activeHandle || sendingRef.current) { + // Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713). + if (!client || !activeHandle || sendingRef.current || !canSend) { return } sendingRef.current = true @@ -3007,15 +3013,17 @@ export default function SessionScreen() { setInput('') try { - await client.sendRequest('terminal.send', { - terminal: activeHandle, - text, - enter: true, - // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap. + await client.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: activeHandle, + text, + enter: true, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { setInput(text) } finally { @@ -3032,29 +3040,15 @@ export default function SessionScreen() { if (accessoryCommit.kind !== 'allow-raw') { return } - const currentClient = clientRef.current - // Why: async IME flushing can outlive the original terminal selection. - const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + await sendTerminalLiveAccessoryRawBytes({ + client: clientRef.current, targetHandle, activeHandle: activeHandleRef.current, - activeSessionTabType: activeSessionTabTypeRef.current + activeSessionTabType: activeSessionTabTypeRef.current, + connState: connStateRef.current, + bytes: input.bytes, + deviceToken: deviceTokenRef.current }) - if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') { - return - } - await currentClient - .sendRequest('terminal.send', { - terminal: rawSendTarget, - text: input.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - .then( - () => undefined, - () => undefined - ) } const sendLiveTerminalInput = useCallback( @@ -3078,15 +3072,19 @@ export default function SessionScreen() { ) { return false } + // Why: live-mirror deltas queued behind a dying send drain into the connect + // wait and replay stale bytes after reconnect (#6713's `YZZYecho …` corruption). return rpc - .sendRequest('terminal.send', { - terminal: handle, - text, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) .then(isTerminalSendRpcAccepted, () => false) }, [showToast] @@ -3348,14 +3346,17 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.add(handle) try { - await rpc.sendRequest('terminal.send', { - terminal: handle, - text: queued.bytes, - enter: false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + // Why: gesture arrows parked across a reconnect would move a TUI long after the swipe. + await rpc.sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: handle, + text: queued.bytes, + enter: false, + deviceToken: deviceTokenRef.current + }), + TERMINAL_INPUT_SEND_OPTIONS + ) } catch { // Transient failure } finally { @@ -3837,14 +3838,15 @@ export default function SessionScreen() { subscribeToTerminal(createdHandle) if (options?.initialPrompt?.trim()) { void client - .sendRequest('terminal.send', { - terminal: createdHandle, - text: options.initialPrompt, - enter: options.enter !== false, - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: createdHandle, + text: options.initialPrompt, + enter: options.enter !== false, + deviceToken: deviceTokenRef.current + }) + ) .then((sendResponse) => { if (!sendResponse.ok) { throw new Error( @@ -4847,9 +4849,10 @@ export default function SessionScreen() { styles.accessoryKey, liveInputEnabled && styles.accessoryKeyActive, pressed && styles.accessoryKeyPressed, - !canSend && styles.accessoryKeyDisabled + !canCompose && styles.accessoryKeyDisabled ]} - disabled={!canSend} + // Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713). + disabled={!canCompose} onPress={toggleLiveInput} accessibilityLabel={ liveInputEnabled @@ -4862,7 +4865,7 @@ export default function SessionScreen() { color={ liveInputEnabled ? colors.bgBase - : canSend + : canCompose ? colors.textSecondary : colors.textMuted } @@ -5057,7 +5060,8 @@ export default function SessionScreen() { autocompleteEnabled )} returnKeyType="send" - editable={canSend} + // Why: composing is local — an outage must not lock the field or discard typed text (#6713). + editable={canCompose} onSubmitEditing={() => void handleSend()} /> { + it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: true }) + }) + + it('Given a cut connection Then composing stays available while sending is blocked', () => { + for (const connState of [ + 'connecting', + 'handshaking', + 'disconnected', + 'reconnecting', + 'auth-failed' + ] as const) { + expect( + resolveMobileTerminalInputGate({ + connState, + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: true, canSend: false }) + } + }) + + it('Given a non-terminal tab or no handle Then neither composing nor sending is allowed', () => { + for (const activeSessionTabType of ['markdown', 'file', 'browser']) { + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: 'terminal-a', + activeSessionTabType + }) + ).toEqual({ canCompose: false, canSend: false }) + } + expect( + resolveMobileTerminalInputGate({ + connState: 'connected', + activeHandle: null, + activeSessionTabType: 'terminal' + }) + ).toEqual({ canCompose: false, canSend: false }) + }) + + it('Given a lagging tab list yielding no tab Then the gate treats the type as unknown, not non-terminal', () => { + expect( + resolveMobileTerminalInputGate({ + connState: 'disconnected', + activeHandle: 'terminal-a', + activeSessionTabType: undefined + }) + ).toEqual({ canCompose: true, canSend: false }) + }) +}) + +describe('session route offline-compose wiring', () => { + it('derives both gates from the shared resolver', () => { + expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({') + }) + + it('keeps the buffered command box editable offline while the live capture stays send-gated', () => { + const bufferedInput = routeSlice( + 'ref={commandInputRef}', + 'onSubmitEditing={() => void handleSend()}' + ) + expect(bufferedInput).toContain('editable={canCompose}') + + const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + expect(liveCapture).toContain('editable={canSend}') + }) + + it('keeps the send button connection-gated so held text cannot fire into a dead link', () => { + const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"') + expect(sendButton).toContain('disabled={!canSend}') + }) + + it('holds composed text when the return key submits offline', () => { + const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true') + expect(handleSend).toContain('!canSend') + }) + + it('keeps the live/buffered mode toggle reachable offline', () => { + const modeToggle = routeSlice( + 'liveInputEnabled && styles.accessoryKeyActive', + 'onPress={toggleLiveInput}' + ) + expect(modeToggle).toContain('disabled={!canCompose}') + }) + + it('tells the live-input commit hook about connection loss so stale mirror state resets', () => { + const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture') + expect(hookCall).toContain("connected: connState === 'connected'") + }) + + it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => { + // Live mirror, buffered send, and gesture arrows must all opt out of the + // connect wait — a parked send replays stale bytes into the PTY. Accessory + // keys get the same option inside terminal-live-accessory-raw-send.ts. + const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0 + expect(optOuts).toBe(4) + expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true }) + }) + + it('tags terminal sends with the device presence lock only when a token exists', () => { + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: true, deviceToken: 'tok' }) + ).toEqual({ terminal: 't1', text: 'ls', enter: true, client: { id: 'tok', type: 'mobile' } }) + expect( + buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: false, deviceToken: null }) + ).toEqual({ terminal: 't1', text: 'ls', enter: false }) + }) +}) diff --git a/mobile/src/terminal/terminal-input-connection-gate.ts b/mobile/src/terminal/terminal-input-connection-gate.ts new file mode 100644 index 00000000000..c8d4da30138 --- /dev/null +++ b/mobile/src/terminal/terminal-input-connection-gate.ts @@ -0,0 +1,26 @@ +import type { ConnectionState } from '../transport/types' + +type MobileTerminalInputGateOptions = { + readonly connState: ConnectionState + readonly activeHandle: string | null + readonly activeSessionTabType: string | null | undefined +} + +type MobileTerminalInputGate = { + // Why: composing is local — it must survive an outage so typed text is held, not discarded (#6713). + readonly canCompose: boolean + readonly canSend: boolean +} + +export function resolveMobileTerminalInputGate({ + connState, + activeHandle, + activeSessionTabType +}: MobileTerminalInputGateOptions): MobileTerminalInputGate { + const canCompose = + activeHandle != null && + activeSessionTabType !== 'markdown' && + activeSessionTabType !== 'file' && + activeSessionTabType !== 'browser' + return { canCompose, canSend: canCompose && connState === 'connected' } +} diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts new file mode 100644 index 00000000000..7a1110b407b --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send' +import type { RpcClient } from '../transport/rpc-client' + +function captureClient(result: Promise = Promise.resolve({ ok: true })) { + const sendRequest = vi.fn(() => result) + return { client: { sendRequest } as unknown as Pick, sendRequest } +} + +const BASE_ARGS = { + targetHandle: 'terminal-a', + activeHandle: 'terminal-a', + activeSessionTabType: 'terminal', + connState: 'connected', + bytes: '', + deviceToken: 'tok' +} as const + +describe('terminal live accessory raw send', () => { + it('sends raw bytes now-or-never with the device presence tag', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { terminal: 'terminal-a', text: '', enter: false, client: { id: 'tok', type: 'mobile' } }, + // Why: an accessory key parked in the connect wait would fire into the PTY long after the tap. + { failWhenDisconnected: true } + ) + }) + + it('drops the bytes instead of sending while disconnected', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('drops the bytes when the terminal selection went stale mid-flush', async () => { + const { client, sendRequest } = captureClient() + + await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('swallows a rejected send so accessory taps never surface transport errors', async () => { + const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send'))) + + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + ).resolves.toBeUndefined() + }) +}) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts new file mode 100644 index 00000000000..792491f6c63 --- /dev/null +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -0,0 +1,43 @@ +import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' +import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +type TerminalLiveAccessoryRawSendArgs = { + readonly client: Pick | null + readonly targetHandle: string + readonly activeHandle: string | null + readonly activeSessionTabType: string | null + readonly connState: ConnectionState + readonly bytes: string + readonly deviceToken: string | null +} + +export async function sendTerminalLiveAccessoryRawBytes( + args: TerminalLiveAccessoryRawSendArgs +): Promise { + // Why: async IME flushing can outlive the original terminal selection. + const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ + targetHandle: args.targetHandle, + activeHandle: args.activeHandle, + activeSessionTabType: args.activeSessionTabType + }) + if (!args.client || !rawSendTarget || args.connState !== 'connected') { + return + } + await args.client + .sendRequest( + 'terminal.send', + buildTerminalSendParams({ + terminal: rawSendTarget, + text: args.bytes, + enter: false, + deviceToken: args.deviceToken + }), + TERMINAL_INPUT_SEND_OPTIONS + ) + .then( + () => undefined, + () => undefined + ) +} diff --git a/mobile/src/terminal/terminal-send-request.ts b/mobile/src/terminal/terminal-send-request.ts new file mode 100644 index 00000000000..e99c6f03c35 --- /dev/null +++ b/mobile/src/terminal/terminal-send-request.ts @@ -0,0 +1,26 @@ +import type { SendRequestOptions } from '../transport/rpc-client' + +type TerminalSendParams = { + readonly terminal: string + readonly text: string + readonly enter: boolean + readonly client?: { readonly id: string; readonly type: 'mobile' } +} + +// Why: keystroke sends must never park in the connect wait — parked sends replay into the PTY after reconnect (#6713). +export const TERMINAL_INPUT_SEND_OPTIONS: SendRequestOptions = { failWhenDisconnected: true } + +export function buildTerminalSendParams(args: { + terminal: string + text: string + enter: boolean + // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. + deviceToken: string | null +}): TerminalSendParams { + return { + terminal: args.terminal, + text: args.text, + enter: args.enter, + ...(args.deviceToken ? { client: { id: args.deviceToken, type: 'mobile' as const } } : {}) + } +} diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index b759e30d615..c211ff447ec 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = { readonly handlers: ReturnType> readonly sent: readonly string[] readonly setActiveSessionTabType: (next: string | undefined) => void + readonly setConnected: (next: boolean) => void + readonly setSendResult: (next: boolean) => void readonly unmount: () => void } @@ -46,15 +48,16 @@ function createTerminalLiveInputCommitHarness({ current: new Set([activeHandle]) } const sent: string[] = [] + let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { current: async (_handle, bytes) => { sent.push(bytes) - return sendResult + return currentSendResult } } - // The hook keeps live-input state in refs, so a change handler alone never - // re-renders; only a prop change (this variable) re-runs the pending-clear effect. + // Refs never re-render; only these variables re-run the hook's clear effects. let currentActiveSessionTabType: string | undefined = 'terminal' + let currentConnected = true let handlers: ReturnType> | null = null let renderer: ReactTestRenderer | null = null @@ -64,6 +67,7 @@ function createTerminalLiveInputCommitHarness({ activeHandleRef, activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, + connected: currentConnected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -98,6 +102,15 @@ function createTerminalLiveInputCommitHarness({ renderer?.update(createElement(Harness)) }) }, + setConnected: (next: boolean): void => { + currentConnected = next + act(() => { + renderer?.update(createElement(Harness)) + }) + }, + setSendResult: (next: boolean): void => { + currentSendResult = next + }, unmount: () => { act(() => renderer?.unmount()) } @@ -340,4 +353,40 @@ describe('terminal live input commit hook', () => { // Then: pending was dropped, so submit sends only the carriage return await vi.waitFor(() => expect(sent).toEqual(['\r'])) }) + + it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => { + // Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect) + const { captures, handlers, sent, setConnected, setSendResult } = + createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('XYZZY') + await vi.waitFor(() => expect(sent).toEqual(['XYZZY'])) + + // When: the outage is finally detected, then the link recovers + setConnected(false) + setSendResult(true) + setConnected(true) + + // Then: the capture was wiped, and fresh typing sends verbatim bytes — not + // 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived + expect(captures.at(-1)).toBe('') + const sentBeforeRecovery = sent.length + handlers.handleLiveInputChange('echo CLEANLINE') + await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE'])) + }) + + it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({ + sendResult: false + }) + handlers.handleLiveInputChange('한') + + // When + setConnected(false) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + + // Then: the outage cleared the held text before the timer could send it + expect(sent).toEqual([]) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 9c707dfbb31..d3b3e3fec09 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions = { readonly activeHandleRef: RefObject readonly activeSessionTabType: TTabType | null | undefined readonly activeSessionTabTypeRef: RefObject + readonly connected: boolean readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly liveInputTerminalHandlesRef: RefObject> @@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit({ activeHandleRef, activeSessionTabType, activeSessionTabTypeRef, + connected, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, @@ -68,6 +70,13 @@ export function useTerminalLiveInputCommit({ setLiveInputCapture }) + useEffect(() => { + // Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send. + if (!connected) { + clearPendingLiveInputCommit() + } + }, [connected, clearPendingLiveInputCommit]) + useEffect(() => { const pendingHandle = pendingLiveInputHandleRef.current if (!pendingHandle) { diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts new file mode 100644 index 00000000000..24015ce829e --- /dev/null +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + this.receive(JSON.stringify({ type: 'e2ee_ready' })) + this.receive('encrypted:{"type":"e2ee_authenticated"}') + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +function track(request: Promise): { read: () => string } { + let outcome = 'pending' + request.then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + return { read: () => outcome } +} + +function terminalSendFrames(socket: MockWebSocket): string[] { + return socket.sent.filter((frame) => frame.includes('terminal.send')) +} + +describe('mobile rpc-client connect-wait replay', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('Given a cut connection When a send opts into failWhenDisconnected Then it rejects now and nothing replays after reconnect', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'YZZY' }, + { failWhenDisconnected: true } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('Not connected: terminal.send') + + // Reconnect; the rejected keystroke must not ride the new socket. + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toEqual([]) + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('Given a cut connection When a caller does not opt in Then the send parks and is delivered on the next socket', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + // Pins the default behavior that motivates the opt-out: parked requests + // replay after reconnect, which is what corrupted post-recovery input. + const request = client.sendRequest('terminal.send', { terminal: 't1', text: 'YZZY' }) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(0) + expect(outcome.read()).toBe('pending') + + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1) + } finally { + client.close() + await request.catch(() => undefined) + } + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 30f6d7a426f..9aebcc28db4 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -54,6 +54,9 @@ export type SendRequestOptions = { * against the post-connect clock, and squeezing them to the floor after a slow * reconnect would fail sends that used to land. */ budgetSpansConnect?: boolean + /** Reject immediately when not connected — a send parked in the connect wait + * replays stale terminal bytes into the PTY after reconnect. */ + failWhenDisconnected?: boolean } type SubscribeOptions = { @@ -1013,6 +1016,9 @@ export function connect( const budget = openRpcRequestBudget(options) const waitStart = budget.startedAt const wasConnected = state === 'connected' + if (options?.failWhenDisconnected && !wasConnected) { + throw new Error(`Not connected: ${method}`) + } await waitForConnected(options?.timeoutMs) if (!wasConnected) { console.log('[net] sendRequest waited for connect', { From 7db0101bcb808d2c3e2f4524f97f1073e8832429 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:29:41 -0700 Subject: [PATCH 016/123] fix(mobile): recover pairing saves from Android SecureStore failures (#11430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): recover pairing save when the Android keystore alias is unusable Orca Mobile could reach a state where pairing succeeded but the host could never be saved, with every attempt failing identically: Could not encrypt the value for key 'orca.host-token.host-...' under keychain 'key_v1'. Caused by: unknown expo-secure-store derives ONE Android keystore alias from the keychain service (`:unauthenticated`) and shares it across every host token, so a single unusable alias rejects all writes. Its built-in self-heal only covers KeyPermanentlyInvalidatedException, and a null-message GeneralSecurityException takes the unrecoverable branch instead — leaving onboarding permanently blocked, which a reinstall does not clear. Route host-token persistence through a keychain generation that rotates to a fresh service (and therefore a fresh alias) only after a write has already failed. Generation 0 keeps expo's default service so tokens written by earlier builds stay readable, reads walk back through retired services, and deletes clear every generation so a rotation cannot strand a live credential. Refs #6600 * fix(mobile): record a keychain rotation before storing the token under it Greptile flagged that a token could be stored under a generation the generation record never captured. `commitGeneration` swallowed the AsyncStorage failure and cached the new generation in memory, so the write succeeded for the rest of the session — but the next launch re-read the old record, and because reads only walk back from the recorded generation they never probed the newer service. The host silently vanished and the user had to re-pair, which is the same class of loss this change set out to fix. Record the rotation first and let a storage failure propagate, so a token is never written under a generation reads won't reach. Advancing the record before the write is safe because reads walk back through every older service; the worst case is one spent generation and one extra probe per miss. * fix(mobile): harden pairing keychain recovery * fix(mobile): harden pairing keychain recovery state * fix(mobile): fail closed on unreadable pairing credentials --- mobile/src/transport/host-store.test.ts | 124 +++++- mobile/src/transport/host-store.ts | 20 +- .../mobile-relay-credential-bundle.test.ts | 9 + .../mobile-relay-credential-bundle.ts | 20 +- .../mobile-relay-direct-upgrade-journal.ts | 20 +- ...mobile-relay-pairing-journal-store.test.ts | 66 ++- .../mobile-relay-pairing-journal-store.ts | 21 +- mobile/src/transport/pairing-keychain.test.ts | 403 ++++++++++++++++++ mobile/src/transport/pairing-keychain.ts | 257 +++++++++++ 9 files changed, 890 insertions(+), 50 deletions(-) create mode 100644 mobile/src/transport/pairing-keychain.test.ts create mode 100644 mobile/src/transport/pairing-keychain.ts diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 918e6e92f93..cee492dfd09 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,7 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock @@ -25,7 +26,7 @@ vi.mock('expo-secure-store', () => ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: platformMock })) vi.mock('./host-credential-cleanup', () => ({ @@ -70,6 +71,7 @@ describe('host-store list mutations', () => { beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + platformMock.OS = 'ios' resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) @@ -179,6 +181,25 @@ describe('host-store list mutations', () => { expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() }) + it('keeps the normal iOS save on the existing default keychain service', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-new', + 'new-token', + { + keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' + } + ) + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -328,3 +349,104 @@ describe('host-store list mutations', () => { expect(storedHostsRaw).toBe('{') }) }) + +describe('host-store pairing save after an Android encryption rejection', () => { + const NEW_HOST = { + id: 'host-1782629088232', + name: 'Host 1', + endpoint: 'ws://192.168.0.56:6769', + publicKeyB64: 'desktop-key', + lastConnected: 0, + deviceToken: 'device-token' + } + // Why: the verbatim Android rejection from #6600 — expo maps a null-message GeneralSecurityException to this. + const ENCRYPT_REJECTION = new Error( + "Could not encrypt the value for key 'orca.host-token.host-1782629088232' under keychain 'key_v1'. Caused by: unknown" + ) + const GENERATION_KEY = 'orca:pairing-keychain-generation' + let storedHostsRaw: string + let storedGenerationRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + platformMock.OS = 'android' + resetMobileRelayHostOverlayStoreForTests() + scheduleCleanupMock.mockReset() + scheduleCleanupMock.mockResolvedValue(undefined) + storedHostsRaw = '[]' + storedGenerationRaw = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + // Why: the generation record is durable on device; a forgetful mock would fake a broken read path. + return key === GENERATION_KEY ? storedGenerationRaw : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHostsRaw = raw + } else if (key === GENERATION_KEY) { + storedGenerationRaw = raw + } + }) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('saves the host when the reported Android failure is alias-local (#6600)', async () => { + const written = new Map() + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + + await expect(saveHost(NEW_HOST)).resolves.toBeUndefined() + + expect(written.get('orca.pairing.v1')).toBe('device-token') + expect(JSON.parse(storedHostsRaw)).toEqual([ + { + id: NEW_HOST.id, + name: NEW_HOST.name, + endpoint: NEW_HOST.endpoint, + publicKeyB64: NEW_HOST.publicKeyB64, + lastConnected: NEW_HOST.lastConnected + } + ]) + }) + + it('still surfaces the failure when no keystore alias can accept the token', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) + }) + + it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => { + const written = new Map() + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + await saveHost(NEW_HOST) + // Why: a fresh process has no token cache, so the host list has to come back off the rotated alias. + resetHostStoreForTests() + secureStoreMock.getItemAsync.mockImplementation( + async (_key: string, options?: { keychainService?: string }) => + written.get(options?.keychainService) ?? null + ) + + const hosts = await loadHosts() + + expect(hosts).toHaveLength(1) + expect(hosts[0]!.deviceToken).toBe('device-token') + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 61ed6742d12..9d16235481c 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, @@ -8,6 +7,12 @@ import { type StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup @@ -27,12 +32,6 @@ const STORAGE_KEY = 'orca:hosts' const TOKEN_KEY_PREFIX = 'orca.host-token.' const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' -// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device. -// Reads/writes stay silent (no biometric prompt) because we don't request access control flags. -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function tokenKey(hostId: string): string { return `${TOKEN_KEY_PREFIX}${hostId}` } @@ -46,7 +45,7 @@ async function readDeviceToken(hostId: string): Promise { if (Platform.OS === 'web') { return AsyncStorage.getItem(webTokenKey(hostId)) } - return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + return readPairingKeychainItem(tokenKey(hostId)) } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise { await AsyncStorage.setItem(webTokenKey(hostId), token) return } - await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS) + await writePairingKeychainItem(tokenKey(hostId), token) } async function deleteDeviceToken(hostId: string): Promise { @@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise { await AsyncStorage.removeItem(webTokenKey(hostId)) return } - await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(tokenKey(hostId)) } async function deleteHostCredentials(hostId: string): Promise { @@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() inflightLoad = null + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 4df9d44430b..185d5410980 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) const secureStore = vi.hoisted(() => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), @@ -7,6 +11,7 @@ const secureStore = vi.hoisted(() => ({ })) const platform = vi.hoisted(() => ({ OS: 'ios' })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', ...secureStore @@ -20,6 +25,7 @@ import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { resetPairingKeychainForTests } from './pairing-keychain' const journal = { metadata: { @@ -62,8 +68,11 @@ describe('mobile relay credential bundle', () => { beforeEach(() => { vi.clearAllMocks() + resetPairingKeychainForTests() platform.OS = 'ios' stored = null + asyncStorage.getItem.mockResolvedValue(null) + asyncStorage.setItem.mockResolvedValue(undefined) secureStore.getItemAsync.mockImplementation(async () => stored) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { stored = value diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index 87f6f3826f4..a08fa052459 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -1,8 +1,12 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -38,10 +42,6 @@ export const MobileRelayCredentialBundleSchema = z export type MobileRelayCredentialBundle = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function credentialKey(hostId: string): string { return `orca.mobile-relay.credentials.${hostId}` } @@ -74,7 +74,7 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(credentialKey(hostId)) if (raw === null) { return null } @@ -91,18 +91,14 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - await SecureStore.setItemAsync( - credentialKey(validated.hostId), - JSON.stringify(validated), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(credentialKey(hostId)) } function requireNativeSecretStore(): void { diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 19775270b45..2493c9e78af 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -1,7 +1,11 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -17,10 +21,6 @@ export const MobileRelayDirectUpgradeJournalSchema = z export type MobileRelayDirectUpgradeJournal = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function journalKey(hostId: string): string { return `orca.mobile-relay.direct-upgrade.${hostId}` } @@ -43,7 +43,7 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(journalKey(hostId)) if (!raw) { return null } @@ -60,18 +60,14 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - await SecureStore.setItemAsync( - journalKey(parsed.hostId), - JSON.stringify(parsed), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(journalKey(hostId)) } function encodeBase64Url(value: Uint8Array): string { diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index 5790eb456aa..cf249a90506 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -31,6 +31,8 @@ import { import type { PairingOffer } from './types' const now = Date.UTC(2026, 6, 13) +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const JOURNAL_PRESENCE_KEY = 'orca:pairing-keychain-presence:orca.mobile-relay.pairing-journal.v1' const offer = { v: 2, endpoint: 'ws://192.168.1.10:6768', @@ -51,6 +53,8 @@ const offer = { describe('mobile relay pairing journal store', () => { let metadataRaw: string | null let secretRaw: string | null + let generationRaw: string | null + let presenceRaw: string | null beforeEach(() => { vi.clearAllMocks() @@ -58,12 +62,32 @@ describe('mobile relay pairing journal store', () => { platform.OS = 'ios' metadataRaw = null secretRaw = null - asyncStorage.getItem.mockImplementation(async () => metadataRaw) - asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { - metadataRaw = value + generationRaw = null + presenceRaw = null + asyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRaw + } + if (key === JOURNAL_PRESENCE_KEY) { + return presenceRaw + } + return metadataRaw }) - asyncStorage.removeItem.mockImplementation(async () => { - metadataRaw = null + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === GENERATION_KEY) { + generationRaw = value + } else if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = value + } else { + metadataRaw = value + } + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = null + } else { + metadataRaw = null + } }) secureStore.getItemAsync.mockImplementation(async () => secretRaw) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { @@ -94,6 +118,36 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) + it('retries the journal under a distinct alias before relay pairing connects (#6600)', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + secureStore.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw new Error( + "Could not encrypt the value for key 'orca.mobile-relay.pairing-journal.v1' under keychain 'key_v1'. Caused by: unknown" + ) + } + secretRaw = value + } + ) + platform.OS = 'android' + + await expect(saveMobileRelayPairingJournal(journal)).resolves.toBeUndefined() + + expect(generationRaw).toBe('1') + expect(secureStore.setItemAsync).toHaveBeenLastCalledWith( + 'orca.mobile-relay.pairing-journal.v1', + expect.any(String), + expect.objectContaining({ keychainService: 'orca.pairing.v1' }) + ) + }) + it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, @@ -223,7 +277,7 @@ describe('mobile relay pairing journal store', () => { hostName: 'Red Panda', randomBytes: (length) => new Uint8Array(length).fill(12) }) - secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + secureStore.setItemAsync.mockRejectedValue(new Error('keychain unavailable')) await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index b7044830da5..d02ebc72284 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { MobileRelayPairingJournalMetadataSchema, @@ -7,12 +6,15 @@ import { type MobileRelayPairingJournal, type MobileRelayPairingJournalMetadata } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} let journalMutation: Promise = Promise.resolve() export async function saveMobileRelayPairingJournal( @@ -39,7 +41,7 @@ export async function saveMobileRelayPairingJournal( // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) - await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + await writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -50,7 +52,7 @@ export async function loadMobileRelayPairingJournal(): Promise { const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) return null } const metadata = parseMetadata(rawMetadata) @@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise { // Why: metadata is the discoverable cleanup pointer; remove it before the // native secret so a second crash can only leave a self-cleaning orphan. await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) } export async function updateMobileRelayPairingJournal( @@ -114,7 +116,7 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise throw new Error('stale mobile relay pairing journal') } await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY) }) journalMutation = mutation.catch(() => {}) return mutation @@ -147,4 +149,5 @@ function requireNativeSecretStore(): void { /** Test-only: drain the module mutation chain between cases. */ export function resetMobileRelayPairingJournalStoreForTests(): void { journalMutation = Promise.resolve() + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/pairing-keychain.test.ts b/mobile/src/transport/pairing-keychain.test.ts new file mode 100644 index 00000000000..fda46d0a328 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) +const platformMock = vi.hoisted(() => ({ OS: 'android' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStoreMock +})) +vi.mock('react-native', () => ({ Platform: platformMock })) + +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' + +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const TOKEN_KEY = 'orca.host-token.host-1782629088232' +const TOKEN_PRESENCE_KEY = `orca:pairing-keychain-presence:${TOKEN_KEY}` + +// Why: the exact Android failure from #6600 — expo maps a null-message GeneralSecurityException to this. +const ENCRYPT_REJECTION = new Error( + `Could not encrypt the value for key '${TOKEN_KEY}' under keychain 'key_v1'. Caused by: unknown` +) + +type Options = { keychainService?: string } | undefined + +function serviceOf(options: Options): string | undefined { + return options?.keychainService +} + +describe('pairing keychain', () => { + let generationRecord: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetPairingKeychainForTests() + platformMock.OS = 'android' + generationRecord = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => + key === GENERATION_KEY ? generationRecord : null + ) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('writes under the default keychain service so existing installs keep their tokens', async () => { + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + const [key, value, options] = secureStoreMock.setItemAsync.mock.calls[0]! + expect(key).toBe(TOKEN_KEY) + expect(value).toBe('token') + // Why: passing any keychainService would change the keystore alias and orphan every already-stored token. + expect(serviceOf(options as Options)).toBeUndefined() + expect(generationRecord).toBeNull() + }) + + it('recovers when the reported Android encryption failure is alias-local', async () => { + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + const rotated = secureStoreMock.setItemAsync.mock.calls.at(-1)! + expect(serviceOf(rotated[2] as Options)).toBe('orca.pairing.v1') + expect(rotated[1]).toBe('token') + expect(generationRecord).toBe('1') + }) + + it('surfaces the original error when a rotated write also fails', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('retries an unconfirmed alias without consuming another generation', async () => { + generationRecord = '1:pending' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('confirms an unconfirmed alias after its first successful write', async () => { + generationRecord = '1:pending' + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(generationRecord).toBe('1') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('does not rotate on an iOS keychain failure', async () => { + platformMock.OS = 'ios' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBeNull() + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + }) + + it('never stores a token under a generation it could not durably record', async () => { + // Why: reads only walk back from the recorded generation, so a token written under an + // unrecorded one is silently unreachable after a relaunch and the host vanishes. + asyncStorageMock.setItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage full') + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + const rotatedWrites = secureStoreMock.setItemAsync.mock.calls.filter( + (call) => serviceOf(call[2] as Options) !== undefined + ) + expect(rotatedWrites).toHaveLength(0) + expect(generationRecord).toBeNull() + }) + + it('records the rotation before storing the token so a relaunch can still find it', async () => { + const order: string[] = [] + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + order.push(`record:${raw}`) + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + const service = serviceOf(options) + if (service === undefined) { + throw ENCRYPT_REJECTION + } + order.push(`store:${service}`) + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(order).toEqual(['record:1:pending', 'store:orca.pairing.v1', 'record:1']) + }) + + it('keeps a successful rotated write reachable when confirmation storage fails', async () => { + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key !== GENERATION_KEY) { + return + } + if (raw === '1') { + throw new Error('storage unavailable') + } + generationRecord = raw + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'token' : null + ) + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('token') + }) + + it('reads through the rotated service once a rotation has been committed', async () => { + generationRecord = '1' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + }) + + it('falls back to a retired service so rotation does not orphan a still-readable token', async () => { + generationRecord = '2' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + // Why: probes must walk v2 -> v1 -> default rather than stopping at the current generation. + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(3) + }) + + it('does not return a stale older value when the current alias throws', async () => { + generationRecord = '1' + const currentError = new Error('Could not decrypt the value') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v1') { + throw currentError + } + return 'legacy-token' + }) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toBe(currentError) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('does not return a stale older value when a recorded current item decrypts as null', async () => { + generationRecord = '1' + let presenceRecord: string | null = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRecord + } + return key === TOKEN_PRESENCE_KEY ? presenceRecord : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + if (key === TOKEN_PRESENCE_KEY) { + presenceRecord = raw + } + }) + + await writePairingKeychainItem(TOKEN_KEY, 'rotated-token') + expect(presenceRecord).toBe('1') + expect(asyncStorageMock.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + + resetPairingKeychainForTests() + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('reads an older value while a newly recorded alias has no item yet', async () => { + generationRecord = '1:pending' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + }) + + it('probes every bounded service when the generation record is temporarily unreadable', async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage unavailable') + } + return null + }) + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(7) + }) + + it('does not rotate from a guessed generation when the record is unreadable', async () => { + const storageError = new Error('storage unavailable') + asyncStorageMock.getItem.mockRejectedValueOnce(storageError) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(storageError) + + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes the token under every generation so rotation cannot strand a live credential', async () => { + generationRecord = '2' + + await deletePairingKeychainItem(TOKEN_KEY) + + const services = secureStoreMock.deleteItemAsync.mock.calls.map((call) => + serviceOf(call[1] as Options) + ) + expect(services).toEqual(['orca.pairing.v2', 'orca.pairing.v1', undefined]) + expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY) + }) + + it('reports a partial delete failure after attempting every generation', async () => { + generationRecord = '2' + const deleteError = new Error('delete failed') + secureStoreMock.deleteItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v2') { + throw deleteError + } + }) + + await expect(deletePairingKeychainItem(TOKEN_KEY)).rejects.toBe(deleteError) + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(3) + expect(asyncStorageMock.removeItem).not.toHaveBeenCalled() + }) + + it('serializes writes that share the global generation', async () => { + let releaseFirst!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirst = resolve + }) + secureStoreMock.setItemAsync.mockImplementationOnce(async () => firstWrite) + + const first = writePairingKeychainItem(TOKEN_KEY, 'first') + const second = writePairingKeychainItem(`${TOKEN_KEY}.second`, 'second') + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)) + + releaseFirst() + await Promise.all([first, second]) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(2) + }) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'probes rotated services when generation record %j is malformed', + async (raw) => { + generationRecord = raw + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'refuses to write from malformed generation record %j', + async (raw) => { + generationRecord = raw + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toThrow( + /generation record is invalid/ + ) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'deletes every bounded service when generation record %j is malformed', + async (raw) => { + generationRecord = raw + + await deletePairingKeychainItem(TOKEN_KEY) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(9) + } + ) + + it('stops rotating at the generation cap rather than probing unbounded services', async () => { + generationRecord = '8' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(generationRecord).toBe('8') + }) +}) diff --git a/mobile/src/transport/pairing-keychain.ts b/mobile/src/transport/pairing-keychain.ts new file mode 100644 index 00000000000..bf3b1810807 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.ts @@ -0,0 +1,257 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps pairing credentials off iCloud Keychain and backup restores. +const BASE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +const GENERATION_STORAGE_KEY = 'orca:pairing-keychain-generation' +const PRESENCE_STORAGE_PREFIX = 'orca:pairing-keychain-presence:' +const SERVICE_PREFIX = 'orca.pairing.v' +// Why: every generation adds one read probe per miss; bound pathological recovery cost. +const MAX_GENERATION = 8 + +/** + * Why: expo-secure-store shares one Android keystore alias across every + * unauthenticated item under a keychain service. The #6600 rejection proves + * encryption reached expo's null-message GeneralSecurityException branch; a + * fresh service selects a distinct alias and can recover an alias-local fault. + */ +function serviceForGeneration(generation: number): string | undefined { + // Why: generation 0 must keep expo's default service so existing credentials stay readable. + return generation <= 0 ? undefined : `${SERVICE_PREFIX}${generation}` +} + +function optionsForGeneration(generation: number): SecureStore.SecureStoreOptions { + const keychainService = serviceForGeneration(generation) + return keychainService ? { ...BASE_OPTIONS, keychainService } : BASE_OPTIONS +} + +type LoadedGeneration = + | { generation: number; pending: boolean; reliable: true } + | { generation: 0; pending: false; reliable: false; error: unknown } + +type GenerationState = { generation: number; pending: boolean } +type PresenceChange = { storageKey: string; previousRaw: string | null } + +let cachedGeneration: GenerationState | null = null +let keychainMutation: Promise = Promise.resolve() + +function parseGeneration(raw: string | null): LoadedGeneration { + if (raw === null) { + return { generation: 0, pending: false, reliable: true } + } + const pending = raw.endsWith(':pending') + const generationRaw = pending ? raw.slice(0, -':pending'.length) : raw + const parsed = Number(generationRaw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + (pending && parsed === 0) || + String(parsed) !== generationRaw + ) { + return { + generation: 0, + pending: false, + reliable: false, + error: new Error('pairing keychain generation record is invalid') + } + } + return { generation: parsed, pending, reliable: true } +} + +function parsePresenceGeneration(raw: string | null): number | null { + if (raw === null) { + return null + } + const parsed = Number(raw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + String(parsed) !== raw + ) { + throw new Error('pairing keychain presence record is invalid') + } + return parsed +} + +function presenceStorageKey(key: string): string { + return `${PRESENCE_STORAGE_PREFIX}${key}` +} + +async function loadPresenceGeneration(key: string): Promise { + if (Platform.OS !== 'android') { + return null + } + return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key))) +} + +async function loadGeneration(): Promise { + if (cachedGeneration !== null) { + return { ...cachedGeneration, reliable: true } + } + try { + const loaded = parseGeneration(await AsyncStorage.getItem(GENERATION_STORAGE_KEY)) + if (loaded.reliable) { + cachedGeneration = { generation: loaded.generation, pending: loaded.pending } + } + return loaded + } catch (error) { + // Why: don't cache or rotate from a guess; a later read may recover the durable pointer. + return { generation: 0, pending: false, reliable: false, error } + } +} + +// Why: reads only walk back from the recorded generation, so persist before writing under it. +async function commitGeneration(generation: number, pending: boolean): Promise { + const raw = pending ? `${generation}:pending` : String(generation) + await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw) + cachedGeneration = { generation, pending } +} + +function enqueueKeychainMutation(operation: () => Promise): Promise { + const mutation = keychainMutation.then(operation) + keychainMutation = mutation.catch(() => {}) + return mutation +} + +async function preparePresenceWrite( + key: string, + generation: number +): Promise { + if (Platform.OS !== 'android') { + return null + } + // Why: Expo Android returns null for both absent and undecryptable entries. + const storageKey = presenceStorageKey(key) + const previousRaw = await AsyncStorage.getItem(storageKey) + parsePresenceGeneration(previousRaw) + await AsyncStorage.setItem(storageKey, String(generation)) + return { storageKey, previousRaw } +} + +async function restorePresence(change: PresenceChange | null): Promise { + if (!change) { + return + } + if (change.previousRaw === null) { + await AsyncStorage.removeItem(change.storageKey) + return + } + await AsyncStorage.setItem(change.storageKey, change.previousRaw) +} + +async function setItemAtGeneration(key: string, value: string, generation: number): Promise { + const presenceChange = await preparePresenceWrite(key, generation) + try { + await SecureStore.setItemAsync(key, value, optionsForGeneration(generation)) + } catch (error) { + // Why: a failed rollback leaves reads fail-closed at the attempted generation. + await restorePresence(presenceChange).catch(() => {}) + throw error + } +} + +export async function readPairingKeychainItem(key: string): Promise { + await keychainMutation + const presenceGeneration = await loadPresenceGeneration(key) + if (presenceGeneration !== null) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration)) + if (value === null) { + throw new Error('pairing keychain item is unavailable at its recorded generation') + } + return value + } + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(candidate)) + if (value !== null) { + return value + } + } + return null +} + +async function writePairingKeychainItemImpl(key: string, value: string): Promise { + const loaded = await loadGeneration() + if (!loaded.reliable) { + throw loaded.error + } + const generation = loaded.generation + let firstError: unknown + try { + await setItemAtGeneration(key, value, generation) + if (loaded.pending) { + await commitGeneration(generation, false) + } + return + } catch (error) { + firstError = error + } + + // Why: retry an unconfirmed alias instead of exhausting generations on a device-wide failure. + if (loaded.pending || !isAndroidEncryptionFailure(firstError)) { + throw firstError + } + const rotated = generation + 1 + if (rotated > MAX_GENERATION) { + throw firstError + } + try { + await commitGeneration(rotated, true) + } catch { + throw firstError + } + try { + await setItemAtGeneration(key, value, rotated) + await commitGeneration(rotated, false) + } catch { + throw firstError + } +} + +function isAndroidEncryptionFailure(error: unknown): boolean { + if (Platform.OS !== 'android' || !error || typeof error !== 'object') { + return false + } + const message = 'message' in error ? error.message : null + return typeof message === 'string' && message.includes('Could not encrypt the value for key') +} + +export function writePairingKeychainItem(key: string, value: string): Promise { + return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value)) +} + +async function deletePairingKeychainItemImpl(key: string): Promise { + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + let firstError: unknown + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + try { + await SecureStore.deleteItemAsync(key, optionsForGeneration(candidate)) + } catch (error) { + firstError ??= error + } + } + if (firstError !== undefined) { + throw firstError + } + if (Platform.OS === 'android') { + await AsyncStorage.removeItem(presenceStorageKey(key)) + } +} + +export function deletePairingKeychainItem(key: string): Promise { + return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key)) +} + +/** Test-only: drop cached generation and mutation state between cases. */ +export function resetPairingKeychainForTests(): void { + cachedGeneration = null + keychainMutation = Promise.resolve() +} From 854f76cb516bd9d90189e1c2947c72e7ff576739 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:37:41 -0700 Subject: [PATCH 017/123] fix(relay): slice PTY flush remainder from the memoized source chunk (#11621) A retry that carries a memoized sourceChunk must republish that exact span and slice the remainder at the memo boundary. Recomputing chunkChars on retry dropped coalesced tails when capacity grew, duplicated already- published chars when it shrank, and discarded coalesced transformed raw advances behind a source-only memo. The remainder entry now also keeps transformed/rawLength/seq bookkeeping so a transformed tail stays a well-formed emission. Co-authored-by: Orca --- .../pty-handler-source-publication.test.ts | 28 +++++ src/relay/pty-handler.test.ts | 117 ++++++++++++++++++ src/relay/pty-handler.ts | 27 ++-- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index a99610671ec..982b64d5c54 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -301,6 +301,34 @@ describe('PtyHandler negotiated source publication', () => { expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1 }) }) + it('republishes the identical memoized span after a failed projection', async () => { + await spawn({}) + const appendSpy = vi.spyOn(adapter, 'appendSource') + const projectSpy = vi + .spyOn(dispatcher, 'projectPtyDataToMatchingClients') + .mockReturnValueOnce(false) + // Why: a larger capacity result on the retry must not move the memoized span boundary. + const maxCharsSpy = vi.spyOn(dispatcher, 'maxLegacyPtyDataChars') + maxCharsSpy.mockReturnValueOnce(4).mockReturnValue(8) + + dataCallback!('abcdefgh') + await vi.advanceTimersByTimeAsync(8) + expect(projectSpy).toHaveBeenCalledTimes(1) + + handler.handleSourcePublicationCapacity('pty-1') + await vi.advanceTimersByTimeAsync(8) + + // Retried projection carries the byte-identical span params. + expect(projectSpy.mock.calls[1][1]).toEqual(projectSpy.mock.calls[0][1]) + const spans = appendSpy.mock.calls.map(([, input]) => input) + expect(spans.filter((span) => span.data === 'abcd')).toHaveLength(1) + expect(spans.map((span) => span.data).join('')).toBe('abcdefgh') + expect(spans.map((span) => [span.displayStart, span.displayEnd])).toEqual([ + [0, 4], + [4, 8] + ]) + }) + it('keeps the native PTY and V1 owner live when one subscriber saturates', async () => { await spawn({}) const detached: number[] = [] diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 1a204088a8b..c81d2b5a6a1 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1340,6 +1340,123 @@ describe('PtyHandler', () => { ]) }) + describe('legacy flush retry with a memoized source chunk', () => { + let capacityListener: (() => void) | undefined + let hasCapacity: boolean + let maxChars: number + let admitted: Record[] + let dataCallback: ((data: string) => void) | undefined + + async function setupRetryHarness(spawnParams: Record = {}): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + capacityListener = undefined + hasCapacity = false + admitted = [] + Object.assign(dispatcher, { + onLegacyPtyCapacity: vi.fn((listener: () => void) => { + capacityListener = listener + return vi.fn() + }), + tryNotifyPtyData: vi.fn((params: Record) => { + if (hasCapacity) { + admitted.push(params) + } + return hasCapacity + }), + tryNotifyPtyExit: vi.fn(() => true), + legacyRetentionBelowLowWater: true, + // Clamped like the real dispatcher: never more than min(data.length, limit). + maxLegacyPtyDataChars: vi.fn((_params: unknown, data: string, limit?: number) => + Math.min(maxChars, data.length, limit ?? data.length) + ) + }) + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + dataCallback = undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + dataCallback = callback + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', spawnParams) + expect(dataCallback).toBeDefined() + } + + it('resends the memo verbatim and keeps the coalesced tail when capacity grows', async () => { + maxChars = 5_000 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + // Why before restoring capacity: the failed batch made zero writes so no flush is + // rescheduled; this enqueue re-arms the timer. + dataCallback!('b'.repeat(10)) + hasCapacity = true + maxChars = 16_384 + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000) + 'b'.repeat(10)) + expect((admitted[0].data as string).length).toBe(5_000) + // Why: remainder frames must not leak transformed/rawLength/seq keys. + expect(admitted[1]).toStrictEqual({ id: 'pty-1', data: expect.any(String) }) + }) + + it('keeps a tail appended after a failed flush under constant capacity', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(5_000)) + await vi.advanceTimersByTimeAsync(8) + dataCallback!('b'.repeat(10)) + hasCapacity = true + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(5_000) + 'b'.repeat(10)) + expect(admitted[1].data).toBe('b'.repeat(10)) + }) + + it('does not duplicate memoized chars when capacity shrinks before the retry', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + maxChars = 5_000 + hasCapacity = true + // Why: nothing else re-arms the flush timer after a zero-write batch. + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000)) + expect((admitted[0].data as string).length).toBe(16_384) + expect((admitted[1].data as string).length).toBe(3_616) + }) + + it('retains a coalesced transformed raw advance behind a memoized source-only chunk', async () => { + maxChars = 16_384 + await setupRetryHarness({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + // First answered query: direct publish fails, entry queued without a memo. + dataCallback!('\x1b]10;?\x07') + // Capacity event while still blocked: the flush fails and memoizes the source-only chunk. + capacityListener!() + await vi.advanceTimersByTimeAsync(0) + // Second answered query coalesces into the memoized transformed head. + dataCallback!('\x1b]11;?\x07') + hasCapacity = true + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted).toEqual([ + { id: 'pty-1', data: '', rawLength: 7, seq: 7, transformed: true }, + { id: 'pty-1', data: '', rawLength: 7, seq: 14, transformed: true } + ]) + }) + }) + it('leaves startup queries untouched for an unsupported relay capability version', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 552c1e8b974..f5275e3fc12 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -894,10 +894,15 @@ export class PtyHandler { ...(pending.rawLength === undefined ? {} : { rawLength: pending.rawLength }), ...(pending.transformed ? { transformed: true } : {}) } - let chunkChars = pending.transformed - ? desiredChars - : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? - desiredChars) + // Why: a failed publish may already have reserved this exact span (source-ledger append, + // partial legacy fan-out), so a retry must resend it verbatim and slice the remainder at + // the memo boundary — capacity and coalesced data can both have changed since. The capacity + // search is skipped on retry: its result is discarded, and publish re-checks capacity. + let chunkChars = + pending.transformed || pending.sourceChunk + ? desiredChars + : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? + desiredChars) if ( chunkChars > 0 && chunkChars < pending.data.length && @@ -914,8 +919,8 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - const chunk = pending.data.slice(0, chunkChars) - const remaining = pending.data.slice(chunkChars) + const chunk = pending.sourceChunk?.data ?? pending.data.slice(0, chunkChars) + const remaining = pending.data.slice(chunk.length) const chunkRawLength = pending.transformed ? pending.rawLength : pending.rawLength === undefined @@ -938,10 +943,16 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - if (remaining) { + // rawLength fallback is defensive only: transformed memos always carry rawLength (ingress meta). + const publishedRawLength = sourceChunk.rawLength ?? sourceChunk.data.length + const remainingRawLength = pending.transformed + ? (pending.rawLength ?? 0) - publishedRawLength + : remaining.length + if (remaining || (pending.transformed && remainingRawLength > 0)) { queue[0] = { data: remaining, - ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.rawLength === undefined ? {} : { rawLength: remainingRawLength }), seq: pending.seq } } else { From 83af12dc3e944a6e84df5254dc11743517d9d8ad Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:48:07 -0700 Subject: [PATCH 018/123] [P2] perf(relay): stop re-encoding frames to size PTY chunks (#11620) * perf(relay): stop re-encoding frames to size PTY chunks maxLegacyPtyDataChars now sizes the full candidate once (the common case) and falls back to a binary search over an exact cheap byte formula instead of fully encoding the frame at every probe. Publish paths thread their already-computed frame estimate into enqueueFrame so each PTY publish encodes the message once for admission instead of twice. Co-authored-by: Orca * fix(relay): preserve dispatcher frame guards --------- Co-authored-by: Orca --- ...dispatcher-frame-guard-regressions.test.ts | 47 +++++ src/relay/dispatcher.test.ts | 193 ++++++++++++++++++ src/relay/dispatcher.ts | 63 ++++-- 3 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 src/relay/dispatcher-frame-guard-regressions.test.ts diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/relay/dispatcher-frame-guard-regressions.test.ts new file mode 100644 index 00000000000..71859e05c2f --- /dev/null +++ b/src/relay/dispatcher-frame-guard-regressions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import type { JsonRpcNotification } from './protocol' + +type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: (client: object, msg: JsonRpcNotification, lane: string) => boolean +} + +describe('RelayDispatcher frame guards', () => { + it('returns zero for invalid active-client limits without encoding', () => { + const dispatcher = new RelayDispatcher(() => true, { + writableHighWaterMark: () => 1024 * 1024, + writableLength: () => 0 + }) + try { + const spy = vi.spyOn(dispatcher as unknown as DispatcherInternals, 'estimateFrameBytes') + for (const limit of [0, -1, Number.NaN]) { + expect(dispatcher.maxLegacyPtyDataChars({ id: 'pty-1' }, 'hello', limit)).toBe(0) + } + expect(spy).not.toHaveBeenCalled() + } finally { + dispatcher.dispose() + } + }) + + it('does not estimate frames after disposal', () => { + const dispatcher = new RelayDispatcher(() => true) + const internals = dispatcher as unknown as DispatcherInternals + const spy = vi.spyOn(internals, 'estimateFrameBytes') + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { + data: { + toJSON: () => { + throw new Error('must not serialize') + } + } + } + } + dispatcher.dispose() + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index aaa083b7743..3ec57dcf899 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' import { encodeJsonRpcFrame, encodeKeepAliveFrame, @@ -716,4 +717,196 @@ describe('RelayDispatcher', () => { } }) }) + + describe('legacy PTY chunk sizing', () => { + type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: ( + client: object, + msg: JsonRpcNotification, + lane: string, + onSettled?: (result: SinkWriteSettlement) => void, + estimatedBytes?: number + ) => boolean + } + + // Pre-optimization sizing loop, kept verbatim for differential verification. + function referenceMaxChars( + capacities: number[], + params: Record, + data: string, + limit: number + ): number { + if (capacities.length === 0) { + return Math.min(data.length, limit) + } + let low = 0 + let high = Math.min(data.length, limit) + while (low < high) { + const mid = Math.ceil((low + high) / 2) + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { ...params, data: data.slice(0, mid) } + } + const bytes = encodeJsonRpcFrame(msg, 0, 0).length + if (capacities.every((capacity) => bytes <= capacity)) { + low = mid + } else { + high = mid - 1 + } + } + return low + } + + function makeDispatcher(highWaterMarks: number[]): { + sized: RelayDispatcher + capacities: number[] + } { + const [primaryHwm, ...rest] = highWaterMarks + const sized = new RelayDispatcher(() => true, { + writableHighWaterMark: () => primaryHwm, + writableLength: () => 0 + }) + for (const hwm of rest) { + sized.attachClient(() => true, { + writableHighWaterMark: () => hwm, + writableLength: () => 0 + }) + } + return { + sized, + capacities: highWaterMarks.map((hwm) => Math.max(0, hwm - relayWriterControlReserve(hwm))) + } + } + + function mulberry32(seed: number): () => number { + let a = seed + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + // Multi-byte UTF-8, astral pairs, lone surrogates, controls, quotes, and backslashes. + const alphabet = 'aZ9 "\\\n\r\té߀中𝄞😀𐀀�' + + it('matches the pre-optimization sizing loop across randomized inputs', () => { + const random = mulberry32(0xc0ffee) + const hwmChoices = [1030, 1100, 1250, 1400, 2048, 1024 * 1024] + for (let trial = 0; trial < 300; trial++) { + const length = Math.floor(random() * 240) + let data = '' + for (let i = 0; i < length; i++) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + const clientCount = 1 + Math.floor(random() * 2) + const hwms = Array.from( + { length: clientCount }, + () => hwmChoices[Math.floor(random() * hwmChoices.length)] + ) + const params = { id: `pty-${trial}`, seq: trial } + const { sized, capacities } = makeDispatcher(hwms) + try { + for (const limit of [0, 1, Math.floor(length / 2), length, length + 17]) { + expect(sized.maxLegacyPtyDataChars(params, data, limit)).toBe( + referenceMaxChars(capacities, params, data, limit) + ) + } + expect(sized.maxLegacyPtyDataChars(params, data)).toBe( + referenceMaxChars(capacities, params, data, data.length) + ) + } finally { + sized.dispose() + } + } + }) + + it('sizes a chunk that fits every client with a single frame encode', () => { + const { sized } = makeDispatcher([1024 * 1024]) + try { + const spy = vi.spyOn(sized as unknown as DispatcherInternals, 'estimateFrameBytes') + const data = 'x'.repeat(16 * 1024) + expect(sized.maxLegacyPtyDataChars({ id: 'pty-1' }, data)).toBe(data.length) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + sized.dispose() + } + }) + + it('publishes PTY data with a single frame estimate', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes') + expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) + expect(frames).toHaveLength(1) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const internals = publisher as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800' } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(true) + expect(frames).toHaveLength(2) + expect( + decodeFirstFrame(frames[1]).payload.equals(decodeFirstFrame(frames[0]).payload) + ).toBe(true) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { + const { sized } = makeDispatcher([1030]) + try { + const internals = sized as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'x'.repeat(512) } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(false) + } finally { + sized.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 833081c65fa..3809b9f5f80 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -193,20 +193,34 @@ export class RelayDispatcher { limit = data.length ): number { const clients = this.activeClients() + const max = Math.min(data.length, limit) if (clients.length === 0) { - return Math.min(data.length, limit) + return max } - let low = 0 - let high = Math.min(data.length, limit) - while (low < high) { - const mid = Math.ceil((low + high) / 2) - const msg: JsonRpcNotification = { + if (!(max > 0)) { + return 0 + } + const fitsAll = (bytes: number): boolean => + clients.every((client) => bytes <= client.writer.producerFrameCapacity) + const sizeFrame = (chunk: string): number => + this.estimateFrameBytes({ jsonrpc: '2.0', method: 'pty.data', - params: { ...params, data: data.slice(0, mid) } - } - const bytes = this.estimateFrameBytes(msg) - if (clients.every((client) => bytes <= client.writer.producerFrameCapacity)) { + params: { ...params, data: chunk } + }) + // Fast path: the whole chunk usually fits — one encode instead of log2(n). + if (fitsAll(sizeFrame(data.slice(0, max)))) { + return max + } + // Exact per-step size: only the escaped data string varies; its quotes are in baseBytes. + const baseBytes = sizeFrame('') + const bytesFor = (chars: number): number => + baseBytes + Buffer.byteLength(JSON.stringify(data.slice(0, chars))) - 2 + let low = 0 + let high = max + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (fitsAll(bytesFor(mid))) { low = mid } else { high = mid - 1 @@ -822,19 +836,21 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification, lane: DispatcherWriterLane, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + // Why: publish paths already sized the frame; avoid a redundant encode. + estimatedBytes?: number ): boolean { if (this.disposed || client.closed) { return false } - const estimatedBytes = this.estimateFrameBytes(msg) + const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) return client.writer.enqueue( lane, () => { const seq = client.nextOutgoingSeq++ return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) }, - estimatedBytes, + frameBytes, onSettled ) } @@ -898,7 +914,7 @@ export class RelayDispatcher { return false } for (let index = 0; index < clients.length; index++) { - if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index])) { + if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index], bytes)) { if (this.disposed || clients[index].closed) { continue } @@ -949,7 +965,7 @@ export class RelayDispatcher { if (!leases) { return false } - return this.enqueueLeasedFrame(client, msg, lane, leases[0], onSettled) + return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled) } private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise { @@ -998,13 +1014,20 @@ export class RelayDispatcher { msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, + estimatedBytes: number, onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueueFrame(client, msg, lane, (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }) + const accepted = this.enqueueFrame( + client, + msg, + lane, + (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }, + estimatedBytes + ) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() From 390ae08232db5ea0d391ef6adabf1e6aecc71636 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:49:08 -0700 Subject: [PATCH 019/123] [P1] fix(ssh): key the PTY model-migration fence by app pty id (#11617) * fix(ssh): key the PTY model-migration fence by app pty id Co-authored-by: Orca * test(ssh): pin the post-recovery checkpoint rekey The finishSourceRecovery app-id rekey had no coverage: reverting it left every suite green while reconnects silently resumed from the stale migration-era checkpoint. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../ssh-pty-output-model-migration.test.ts | 83 +++++++++++ .../ipc/ssh-pty-output-source-obligations.ts | 48 +++--- .../ssh-relay-session-recovery-races.test.ts | 139 +++++++++--------- src/main/ssh/ssh-relay-session.ts | 8 +- 4 files changed, 189 insertions(+), 89 deletions(-) diff --git a/src/main/ipc/ssh-pty-output-model-migration.test.ts b/src/main/ipc/ssh-pty-output-model-migration.test.ts index e8007a0c274..ae56e2e45fc 100644 --- a/src/main/ipc/ssh-pty-output-model-migration.test.ts +++ b/src/main/ipc/ssh-pty-output-model-migration.test.ts @@ -1,10 +1,93 @@ import { describe, expect, it, vi } from 'vitest' +import type { SshPtyOutputDataEvent } from './ssh-pty-output-intake' import { createSshPtyOutputIntakeHarness as createHarness, sshPtyOutputEvent as event } from './ssh-pty-output-intake-test-harness' +// Production-shaped ids: bare relay id on the wire side, prefixed app id in intake. +const APP_ID = 'ssh:conn@@pty-1' +const prodEvent = (overrides: Partial = {}): SshPtyOutputDataEvent => + event({ + id: APP_ID, + source: { + relayPtyId: 'pty-1', + spanId: 'span-a', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 0, + sourceEndSu: 4 + }, + ...overrides + }) + describe('SshPtyOutputModelMigration', () => { + it('fences an in-flight admission when relay and app pty ids differ', async () => { + const harness = createHarness() + const first = harness.intake.acceptData(prodEvent({ data: 'aaaa' })) + harness.completions[0]!.resolve() + await first + const second = harness.intake.acceptData( + prodEvent({ + data: 'bbbb', + source: { + relayPtyId: 'pty-1', + spanId: 'span-b', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 4, + sourceEndSu: 8 + } + }) + ) + + const migration = harness.intake.beginGenerationMigration(1) + expect([...migration.byPty.keys()]).toEqual([APP_ID]) + const result = migration.byPty.get(APP_ID) + await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toBe('pending') + await expect(Promise.race([migration.completion, Promise.resolve('pending')])).resolves.toBe( + 'pending' + ) + expect(harness.intake.getAcceptedSourceCheckpoints(1)[0]).toMatchObject({ + id: APP_ID, + acceptedSourceEndSu: 4 + }) + + harness.completions[1]!.resolve() + await expect(second).resolves.toMatchObject({ sequence: 8 }) + await expect(result).resolves.toMatchObject({ + status: 'settled', + checkpoint: { id: APP_ID, acceptedSourceEndSu: 8 } + }) + await migration.completion + }) + + it('targets the real pty on migration timeout with production-shaped ids', async () => { + vi.useFakeTimers() + try { + const resetModelForMigration = vi.fn() + const harness = createHarness({ resetModelForMigration }) + const receipt = harness.intake.acceptData(prodEvent()) + const migration = harness.intake.beginGenerationMigration(1, 10_000) + + await vi.advanceTimersByTimeAsync(10_000) + + await expect(migration.byPty.get(APP_ID)).resolves.toEqual({ + status: 'checkpoint-unavailable', + reason: 'timeout' + }) + await expect(receipt).rejects.toThrow('ssh_model_migration_timeout') + expect(resetModelForMigration).toHaveBeenCalledOnce() + expect(resetModelForMigration).toHaveBeenCalledWith(1, APP_ID) + harness.completions[0]!.resolve() + await migration.completion + } finally { + vi.useRealTimers() + } + }) + it('fences a running source span before exporting its migration checkpoint', async () => { const harness = createHarness() const first = harness.intake.acceptData( diff --git a/src/main/ipc/ssh-pty-output-source-obligations.ts b/src/main/ipc/ssh-pty-output-source-obligations.ts index 28f13dc2d73..410b8c05644 100644 --- a/src/main/ipc/ssh-pty-output-source-obligations.ts +++ b/src/main/ipc/ssh-pty-output-source-obligations.ts @@ -41,7 +41,12 @@ export class SshPtyOutputSourceObligations { private readonly coordinator: SshPtySourceObligationCoordinator private readonly remoteConsumers: SshPtyRemoteSourceRangeConsumers private readonly openedTokens = new Set() - private readonly identityByPty = new Map() + // Why: fence/recovery consumers key checkpoints by app pty id, but the wire + // ACK path needs the relay id kept on identity.id — record both. + private readonly identityByPty = new Map< + string, + Readonly<{ appPtyId: string; identity: PtySourceDeliveryIdentity }> + >() constructor(publish: SshPtyOutputDataEventPublisher | undefined) { this.coordinator = new SshPtySourceObligationCoordinator({ @@ -68,7 +73,7 @@ export class SshPtyOutputSourceObligations { if (!this.openedTokens.has(tokenKey)) { this.coordinator.open(identity, span.sourceStartSu) this.openedTokens.add(tokenKey) - this.identityByPty.set(this.ptyKey(event), identity) + this.identityByPty.set(this.ptyKey(event), Object.freeze({ appPtyId: event.id, identity })) } return Object.freeze({ span, @@ -132,21 +137,21 @@ export class SshPtyOutputSourceObligations { } sealPty(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.seal(identity) } } markExitPublished(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.markExitPublished(identity) } } whenPtyTerminal(event: SshPtyOutputExitEvent): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity return identity ? this.coordinator.whenTerminal(identity) : Promise.resolve() } @@ -154,7 +159,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, cancel: (request: SshPtySourceCancellationRequest) => Promise ): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return null } @@ -171,7 +176,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): boolean { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return false } @@ -183,7 +188,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.applyRecoveryCancellationProof(identity, proof) } @@ -198,8 +203,8 @@ export class SshPtyOutputSourceObligations { this.openedTokens.delete(key) } } - for (const [key, identity] of this.identityByPty) { - if (identity.providerGeneration === providerGeneration) { + for (const [key, record] of this.identityByPty) { + if (record.identity.providerGeneration === providerGeneration) { this.identityByPty.delete(key) } } @@ -207,19 +212,20 @@ export class SshPtyOutputSourceObligations { acceptedCheckpoints(providerGeneration: number): readonly SshPtyAcceptedSourceCheckpoint[] { const checkpoints: SshPtyAcceptedSourceCheckpoint[] = [] - for (const identity of this.identityByPty.values()) { - if (identity.providerGeneration !== providerGeneration) { + for (const record of this.identityByPty.values()) { + if (record.identity.providerGeneration !== providerGeneration) { continue } checkpoints.push( Object.freeze({ - id: identity.id, - providerGeneration: identity.providerGeneration, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(identity) + // Why: fence/recovery keys are app pty ids; the bare relay id stays on identity.id. + id: record.appPtyId, + providerGeneration: record.identity.providerGeneration, + clientGeneration: record.identity.clientGeneration, + ownerGeneration: record.identity.ownerGeneration, + ptyIncarnation: record.identity.ptyIncarnation, + deliveryToken: record.identity.deliveryToken, + acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(record.identity) }) ) } @@ -284,8 +290,8 @@ export class SshPtyOutputSourceObligations { private removeIdentity(identity: PtySourceDeliveryIdentity): void { this.openedTokens.delete(ptySourceDeliveryKey(identity)) - for (const [key, candidate] of this.identityByPty) { - if (samePtySourceDelivery(candidate, identity)) { + for (const [key, record] of this.identityByPty) { + if (samePtySourceDelivery(record.identity, identity)) { this.identityByPty.delete(key) } } diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 711f926e056..43354ebb90c 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -166,6 +166,25 @@ describe('SshRelaySession recovery race fencing', () => { }) } + function pendingRecovery(recoveryEndSu: number) { + return { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu + } + } + + function completeRecovery(params: Record): void { + const complete = onNotificationByMethodMock.mock.calls.findLast( + ([method]) => method === 'pty.recoveryComplete' + )?.[1] as ((params: Record) => void) | undefined + complete?.(params) + } + async function prepareRecovery(targetId: string): Promise<{ session: SshRelaySession deps: ReturnType @@ -242,15 +261,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -320,15 +331,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -401,10 +404,7 @@ describe('SshRelaySession recovery race fencing', () => { const { session, deps } = await prepareRecovery(targetId) attachForReconnectMock.mockImplementation(async () => { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -416,15 +416,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - } + sourceRecovery: pendingRecovery(4) } }) await session.reconnect(deps.mockConn) @@ -463,10 +455,7 @@ describe('SshRelaySession recovery race fencing', () => { sourceStartSu: 5, sourceEndSu: 8 }) - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -478,15 +467,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - } + sourceRecovery: pendingRecovery(8) } }) await session.reconnect(deps.mockConn) @@ -559,15 +540,7 @@ describe('SshRelaySession recovery race fencing', () => { } attachForReconnectMock.mockResolvedValue({ incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 12 - }, + sourceRecovery: pendingRecovery(12), sourceActivationLease }) @@ -651,15 +624,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease: activationLease } }) @@ -704,6 +669,51 @@ describe('SshRelaySession recovery race fencing', () => { } ) + it('overwrites the migration checkpoint with the post-recovery one for the same pty', async () => { + const targetId = 'post-recovery-checkpoint-rekey' + const { session, deps } = await prepareRecovery(targetId) + attachForReconnectMock.mockImplementation(async () => { + queueMicrotask(() => { + emitSourceFrame({ + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu: 4, + sourceEndSu: 8 + }) + completeRecovery({ + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu: 4 + }) + }) + return { incarnationId: 'incarnation-1', sourceRecovery: pendingRecovery(4) } + }) + await session.reconnect(deps.mockConn) + expect(acceptOutputDataMock).toHaveBeenCalledOnce() + + // The pre-migration identity is gone from the intake, so only the checkpoint + // recorded after recovery can answer the next reconnect. + vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } + }) + await session.reconnect(deps.mockConn) + + expect(attachForReconnectMock).toHaveBeenCalledTimes(2) + expect(attachForReconnectMock.mock.calls.at(-1)?.[2]).toMatchObject({ + status: 'checkpoint', + deliveryToken: 'new-token', + acceptedSourceEndSu: 8 + }) + }) + it('keeps a stale overlapping recovery from canceling or mutating its replacement', async () => { const targetId = 'overlapping-recovery' const { session, deps } = await prepareRecovery(targetId) @@ -723,10 +733,7 @@ describe('SshRelaySession recovery race fencing', () => { const ownerGeneration = openConsumerSessionMock.mock.calls.length if (ownerGeneration === 3) { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 3, ownerGeneration: 3, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index aef993f8eef..d5dba94c80b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2119,6 +2119,8 @@ export class SshRelaySession { } const checkpoints = recovery?.checkpointsByAppPtyId const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) + // Why: every checkpoint writer records app-id keys now, so the relay-id + // lookup (and its paired delete below) is a legacy guard only. const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) if (!checkpoint) { return Object.freeze({ status: 'checkpointUnavailable' }) @@ -2273,10 +2275,12 @@ export class SshRelaySession { (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), acceptedRecovery.recoveryEndSu ) + // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed + // by a staler app-id entry on the next sourceRecoveryRequest lookup. ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.set( - relayPtyId, + appPtyId, Object.freeze({ - id: relayPtyId, + id: appPtyId, providerGeneration: this.activePtyProviderGeneration!, clientGeneration: acceptedRecovery.clientGeneration, ownerGeneration: acceptedRecovery.ownerGeneration, From 3b7ea59c5b4dd81006fdc57020d0c93d891c6116 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 17:49:32 -0700 Subject: [PATCH 020/123] fix(windows): make the GPU fallback actually remove the GPU child, and stop WSL latching absent (#11295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): make the GPU fallback actually remove the GPU child, and stop WSL latching absent Three Windows crash/regression fixes from shipped 1.4.156/1.4.158/1.4.159 crash reports. GPU fallback (cluster D, 14 reports, exit 0x80000003 STATUS_BREAKPOINT): the software-rendering fallback called disableHardwareAcceleration() plus --disable-gpu, neither of which removes the GPU child process — Chromium still spawns it to host Viz and merely drops the backend to software GL. Measured on Windows 11 / Electron 43.1.0: gpuProcessCount stays 1. So a GPU process being killed by a bad driver or an injected DLL kept dying after the fallback engaged, on every launch, for the life of that build (the marker is sticky per version). The crash tails show exactly this: gpu_fallback_applied followed by another GPU crash 1.3s later. --in-process-gpu is the only switch that drops the child count to 0; --disable-software-rasterizer is deliberately excluded because it also kills SwiftShader, which would drop every terminal to the DOM renderer. WSL distro list: a successful-but-empty `wsl --list --quiet` was cached for the process lifetime. `wsl --install` reports zero distros while one is still provisioning, so an early probe latched "no WSL" until restart — WSL appeared during setup and then vanished from the terminal picker. Empty results now re-probe on an exponential window (15s doubling to a 5min cap) while staying readable, so a missing distro is still visible to isKnownMissingDistro. WSL availability: isWslAvailable() latched false on any failure via a bare catch, so one slow wsl.exe activation disabled WSL for the whole session. Failures are now classified — a numeric exit status or ENOENT is answer-shaped and holds for 10min, anything else (timeout, spawn failure) retries after 45s — and both back off per consecutive failure, mirroring isPwshAvailable. Windows-only: every changed path is behind an existing process.platform check, so macOS and Linux behaviour is unchanged. * fix(windows): drop a stale WSL availability failure once a distro list succeeds The distro-list and availability caches expire independently, and getWslRepairReason checks availability first. So a definitive availability failure (numeric exit status or ENOENT) held for 10-30min would keep reporting `wsl-unavailable` even after `wsl --list --quiet` successfully returned a distro — i.e. over a WSL that demonstrably just answered. That is the same latch class this branch fixes, surviving in the gap between the two caches. A non-empty distro list proves wsl.exe ran, so drop the negative availability cache and let the next call re-probe. Scoped to non-empty lists only: those are cached for the process lifetime, so this cannot re-spawn the blocking 5s probe more than once. An empty list keeps its failure cache, since it re-probes on a 15s-to-5min schedule and would otherwise pay the blocking probe far too often. * fix(windows): harden GPU safe mode and WSL recovery * fix(wsl): make capability refresh cleanup explicit --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/tsconfig.tc.web.json | 2 + docs/agent-status-over-wsl.md | 9 +- .../gpu-fallback-restart-prompt.test.ts | 6 +- .../gpu-fallback-restart-prompt.ts | 6 +- src/main/index.ts | 42 +- .../startup/gpu-fallback-switches.test.ts | 25 + src/main/startup/gpu-fallback-switches.ts | 35 ++ src/main/wsl-distro-list-output.test.ts | 14 + src/main/wsl-distro-list-output.ts | 11 + src/main/wsl-distro-retry.ts | 4 + src/main/wsl.test.ts | 558 ++++++++++++++++++ src/main/wsl.ts | 228 +++++-- .../lib/windows-terminal-capabilities.test.ts | 61 ++ .../src/lib/windows-terminal-capabilities.ts | 19 +- .../lib/windows-terminal-capability-read.ts | 24 +- 15 files changed, 970 insertions(+), 74 deletions(-) create mode 100644 src/main/startup/gpu-fallback-switches.test.ts create mode 100644 src/main/startup/gpu-fallback-switches.ts create mode 100644 src/main/wsl-distro-list-output.test.ts create mode 100644 src/main/wsl-distro-list-output.ts create mode 100644 src/main/wsl-distro-retry.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f4fb2195223..39756947028 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -11,6 +11,8 @@ "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/ipc/worktree-path-comparison.ts", + "../src/main/wsl-distro-list-output.ts", + "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index e5a6ac9aa3b..5ea8992eda1 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -286,9 +286,12 @@ Four independent review lenses over the full diff; confirmed findings fixed: Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced install dirs accrete across upgrades (~200KB each); an outdated running daemon /p-translates the guest endpoint path until it restarts (hook scripts fall back to env -coords, which same-port binding keeps correct); `wslDistroCache` caches a transient -empty list for the app run (pre-existing semantics, now load-bearing for default-distro -resolution); default-distro resolution caches the first answer for the app run. +coords, which same-port binding keeps correct); default-distro resolution caches the +first non-empty answer for the app run. + +`wslDistroCache` no longer caches a transient empty list for the app run — an empty +result is re-probed after a short window, so a distro registered mid-session is picked +up. `isWslAvailable` likewise re-probes instead of latching a failure. ## 2026-07-09 round-4 external adversarial review diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts index 4a23aaa68ee..e8c8fe8ee21 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts @@ -22,13 +22,13 @@ describe('promptForGpuFallbackRestart', () => { await expect(promptForGpuFallbackRestart(parentWindow as never)).resolves.toBe('restart') expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' }) }) diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts index 52235faecf3..3c53089867b 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts @@ -4,13 +4,13 @@ export type GpuFallbackRestartDecision = 'restart' | 'continue' const GPU_FALLBACK_RESTART_OPTIONS: MessageBoxOptions = { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' } export async function promptForGpuFallbackRestart( diff --git a/src/main/index.ts b/src/main/index.ts index 5dbd92065f0..59eeccbbad7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -127,6 +127,7 @@ import { type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' +import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -1518,10 +1519,13 @@ function maybeApplyGpuFallbackForThisLaunch(): void { return } app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') }) } @@ -1541,23 +1545,6 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro crashesInWindow: result.crashesInWindow }) const engagedAt = Date.now() - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return - } - try { - writeGpuFallbackMarker( - app.getPath('userData'), - { - engagedAt, - crashesInWindow: result.crashesInWindow - }, - environment - ) - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return - } const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined let restartDecision: GpuFallbackRestartDecision try { @@ -1578,6 +1565,23 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) return } + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return + } + try { + writeGpuFallbackMarker( + app.getPath('userData'), + { + engagedAt, + crashesInWindow: result.crashesInWindow + }, + environment + ) + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return + } isQuitting = true relaunchApp('gpu-fallback', fallbackData) // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. diff --git a/src/main/startup/gpu-fallback-switches.test.ts b/src/main/startup/gpu-fallback-switches.test.ts new file mode 100644 index 00000000000..b1ae28d0d5e --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' + +describe('GPU fallback command-line switches', () => { + it('applies nothing outside Windows', () => { + for (const platform of ['darwin', 'linux'] as const) { + const appendSwitch = vi.fn() + expect(applyGpuFallbackCommandLineSwitches({ appendSwitch }, platform)).toEqual([]) + expect(appendSwitch).not.toHaveBeenCalled() + } + }) + + // Why: measured on Windows 11 / Electron 43.1.0 — `--disable-gpu` alone still reports + // GPU: 1 in app.getAppMetrics(); adding --in-process-gpu drops it to 0. + it('appends exactly the measured switch set to the Electron command line', () => { + const appendSwitch = vi.fn() + const appliedSwitches = applyGpuFallbackCommandLineSwitches({ appendSwitch }, 'win32') + expect(appliedSwitches).toEqual([ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' + ]) + expect(appendSwitch.mock.calls.map(([name]) => name)).toEqual(appliedSwitches) + }) +}) diff --git a/src/main/startup/gpu-fallback-switches.ts b/src/main/startup/gpu-fallback-switches.ts new file mode 100644 index 00000000000..9c0b5ef8ff7 --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.ts @@ -0,0 +1,35 @@ +/** Minimal surface of `app.commandLine` this module needs, so callers can be tested. */ +export type GpuFallbackCommandLine = { + appendSwitch(name: string, value?: string): void +} + +/** + * Why: `disableHardwareAcceleration()` + `--disable-gpu` still spawns a GPU child + * (measured on Windows 11 / Electron 43.1.0) — it only drops the backend to software + * GL, so a GPU process being killed by a bad driver or injected DLL keeps dying after + * the fallback engages. `--in-process-gpu` is the only switch that removes the child. + * SwiftShader is disabled because `--in-process-gpu` would otherwise move untrusted + * WebGL command parsing into Orca's privileged main process. Terminals safely fall + * back to the DOM renderer in this post-crash mode. + */ +const GPU_FALLBACK_COMMAND_LINE_SWITCHES = [ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' +] as const + +/** Software rendering is a Windows-only post-crash fallback; every other platform gets nothing. */ +function resolveGpuFallbackSwitches(platform: NodeJS.Platform): readonly string[] { + return platform === 'win32' ? GPU_FALLBACK_COMMAND_LINE_SWITCHES : [] +} + +export function applyGpuFallbackCommandLineSwitches( + commandLine: GpuFallbackCommandLine, + platform: NodeJS.Platform +): readonly string[] { + const switches = resolveGpuFallbackSwitches(platform) + for (const name of switches) { + commandLine.appendSwitch(name) + } + return switches +} diff --git a/src/main/wsl-distro-list-output.test.ts b/src/main/wsl-distro-list-output.test.ts new file mode 100644 index 00000000000..aa8e52fb9aa --- /dev/null +++ b/src/main/wsl-distro-list-output.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' + +describe('WSL distro list output', () => { + it('normalizes NUL-padded output and the default-distro marker', () => { + expect(parseWslDistros('*\0 \0U\0b\0u\0n\0t\0u\0\r\0\n\0')).toEqual(['Ubuntu']) + }) + + it('excludes Docker-managed distros', () => { + expect(filterUserWslDistros(['docker-desktop', 'Ubuntu', 'docker-desktop-data'])).toEqual([ + 'Ubuntu' + ]) + }) +}) diff --git a/src/main/wsl-distro-list-output.ts b/src/main/wsl-distro-list-output.ts new file mode 100644 index 00000000000..53f243d2d1f --- /dev/null +++ b/src/main/wsl-distro-list-output.ts @@ -0,0 +1,11 @@ +export function filterUserWslDistros(distros: string[]): string[] { + return distros.filter((distro) => !distro.toLowerCase().startsWith('docker-desktop')) +} + +export function parseWslDistros(output: string): string[] { + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter(Boolean) +} diff --git a/src/main/wsl-distro-retry.ts b/src/main/wsl-distro-retry.ts new file mode 100644 index 00000000000..f5a9922ac68 --- /dev/null +++ b/src/main/wsl-distro-retry.ts @@ -0,0 +1,4 @@ +export function wslDistroListRetryDelayMs(emptyStreak: number): number { + // Provisioning is one-time, so bounded backoff avoids polling docker-desktop-only hosts. + return Math.min(15_000 * 2 ** (emptyStreak - 1), 5 * 60_000) +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 597b1e795f4..0f618002b46 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -17,7 +17,12 @@ vi.mock('child_process', async (importOriginal) => { import { _resetWslCachesForTests, + _setWslCachesForTests, + getCachedWslAvailability, getCachedWslDistros, + hasCachedWslAvailability, + hasCachedWslDistros, + isWslAvailable, listWslDistros, listWslDistrosAsync, parseWslPath, @@ -98,6 +103,559 @@ describe('WSL distro discovery cache', () => { vi.useRealTimers() } }) + + // Why: `wsl --install` succeeds and reports zero distros while the distro is + // still being provisioned (or until the required reboot). Caching that empty + // success for the process lifetime is why users saw WSL offered during setup + // and then permanently absent from the terminal picker. + it('retries asynchronous discovery after wsl.exe reports no distros yet', async () => { + vi.useFakeTimers() + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '') + }) + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + try { + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + vi.advanceTimersByTime(15_000) + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + it('retries synchronous discovery after wsl.exe reports no distros yet', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty result must still not let every caller re-spawn a blocking + // wsl.exe; it reuses the same brief negative-cache window as a hard failure. + it('bounds the wsl.exe spawn rate while no distros are installed', () => { + execFileSyncMock.mockReturnValue('') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('bounds the asynchronous spawn rate while no distros are installed', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '') + }) + + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + }) + + it('still caches a non-empty distro list for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: docker-desktop entries filter to zero user distros, which is the same + // "nothing installed yet" state as an empty list — a distro installed later + // must still appear. The answer stays readable as [] so a missing distro is + // still visible to `isKnownMissingDistro`. + it('re-probes a docker-desktop-only machine once a user distro can appear', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('docker-desktop\ndocker-desktop-data\n') + execFileSyncMock.mockReturnValueOnce('docker-desktop\nUbuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a docker-desktop-only machine filters to empty on every probe, so a flat + // window would re-spawn wsl.exe every 15s for the whole session. + it('backs off while the list keeps coming back empty', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second empty result doubles the window, so 15s more is not enough. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) + }) + } finally { + vi.useRealTimers() + } + }) + + it('backs off after repeated distro-list failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw new Error('transient failure') + }) + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: listWslDistrosAsync has no in-flight dedupe, so two probes can resolve out of order. + // The late empty answer must not erase the list the newer probe already found. + it('does not let a late empty probe erase a list a newer probe found', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, '') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + it('does not let an older non-empty probe overwrite a newer list', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, 'Debian\n') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + // Why: N startup callers each land with the same empty answer; counting each one + // would arm the 5min cap on the first round and hide a distro for that whole time. + it('counts one empty result per window when probes overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] + expect(callbacks).toHaveLength(3) + for (const callback of callbacks) { + callback(null, '') + } + await Promise.all(pending) + + // One window elapsed, not three doublings, so the next probe runs at 15s. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an overlapping failure and empty result must count as one retry window. + it('holds the base window when a failure and an empty result overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const failing = listWslDistrosAsync() + const empty = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + callbacks[0](new Error('transient failure'), '') + callbacks[1](null, '') + await Promise.all([failing, empty]) + + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).not.toHaveBeenCalled() + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not shorten an empty-list backoff when an overlapping probe fails', async () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + + const empty = listWslDistrosAsync() + const failing = listWslDistrosAsync() + callbacks[0](null, '') + callbacks[1](new Error('transient failure'), '') + await Promise.all([empty, failing]) + + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the cap is the only bound on how long a distro installed mid-session stays + // invisible, so pin it rather than letting the doubling run away. + it('caps the empty-list backoff at five minutes', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + // Windows double to 15/30/60/120/240s, so the 6th would be 480s uncapped. + for (const delayMs of [0, 15_000, 30_000, 60_000, 120_000, 240_000]) { + vi.advanceTimersByTime(delayMs) + listWslDistros() + } + expect(execFileSyncMock).toHaveBeenCalledTimes(6) + vi.advanceTimersByTime(300_000) + listWslDistros() + expect(execFileSyncMock).toHaveBeenCalledTimes(7) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list is a real probe result and must keep driving the + // `wsl-distro-missing` repair prompt even once stale. Going null instead fails + // open and silently spawns `wsl.exe -d ` for a distro Orca saw was absent. + it('keeps reporting an empty result after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(getCachedWslDistros()).toEqual([]) + expect(hasCachedWslDistros()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a genuinely missing distro yields a non-empty list, which never expires, + // so repair-required still fires for the case that warrants it. + it('keeps reporting a known distro list indefinitely', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + vi.advanceTimersByTime(600_000) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('WSL availability cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + // Why: a cold WSL2 utility-VM boot on a just-installed or just-rebooted + // machine routinely exceeds the 5s probe timeout. Latching false for the + // process lifetime is what makes WSL vanish from the picker after setup. + it('retries availability after a probe timeout instead of latching false', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a timeout must not + // let every caller re-spawn it immediately. + it('bounds the probe rate while the failure window is open', () => { + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('caches a successful probe for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('') + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(true) + expect(isWslAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: the resolver reads the cached getter, not the probe. Reporting the last + // observed answer keeps the `wsl-unavailable` repair prompt reachable; going null + // on staleness would let git and PTY silently resolve to a WSL that just failed. + it('keeps reporting the last observed answer after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(getCachedWslAvailability()).toBe(false) + expect(hasCachedWslAvailability()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an answer-shaped failure earns a long window, not a session-long latch — + // wsl.exe also exits non-zero while the WSL package is servicing or LxssManager is + // still starting, which is transient. + it.each([ + ['wsl.exe reports WSL unusable', { status: 1 }], + ['wsl.exe is not installed', { code: 'ENOENT' }] + ])('holds a definitive failure far longer than a timeout when %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('definitive failure'), errorShape) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(10 * 60_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a wedged wsl.exe must not + // be re-probed on every window boundary for the rest of the session. + it('backs off after repeated probe failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second failure doubles the window, so the next boundary is not enough. + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a seeded retryable failure must re-probe once its window lapses, the same + // as one observed live — otherwise test setup can hide the latch this fixes. + it('re-probes a seeded retryable failure once its window lapses', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + _setWslCachesForTests({ available: false, availabilityRetryable: true }) + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the caches expire independently and `getWslRepairReason` checks availability + // first, so a definitive failure held for 10min would report `wsl-unavailable` over a + // WSL that just listed a distro. Finding a distro must drop the stale failure. + it.each([ + ['a definitive failure', { status: 1 }], + ['a timeout', { code: 'ETIMEDOUT', status: null, signal: 'SIGTERM' }] + ])('re-probes availability once a distro list succeeds after %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), errorShape) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + + // A distro turns up (WSL finished provisioning / was repaired mid-session). + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + expect(listWslDistros()).toEqual(['Ubuntu']) + + // Without dropping the stale failure this would stay false for 10min. + execFileSyncMock.mockReturnValueOnce('') + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list re-probes on a 15s-to-5min schedule, so clearing the availability + // failure on every empty probe would re-spawn the blocking 5s probe far too often. + it('does not drop an availability failure for an empty distro list', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), { status: 1 }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + execFileSyncMock.mockReturnValueOnce('') + expect(listWslDistros()).toEqual([]) + expect(getCachedWslAvailability()).toBe(false) + // Still inside the definitive window, so no re-probe was paid. + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting unavailable off Windows without probing', () => { + withPlatform('darwin', () => { + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) }) describe('wsl path helpers', () => { diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 504ac981010..b45c40fe9d3 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,5 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' +import { wslDistroListRetryDelayMs } from './wsl-distro-retry' export { toWindowsWslPath } from '../shared/wsl-paths' @@ -99,26 +101,66 @@ let wslDistroCache: string[] | null = null // otherwise hide every distro until restart), but repeated failures cannot // re-spawn a blocking wsl.exe on every caller; brief negative caching bounds // the spawn rate on machines where WSL is absent or persistently broken. -const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000 -let wslDistroListFailedUntilMs = 0 +// Empty and failed probes back off from 15 seconds to a five-minute cap. +let wslDistroListRetryAfterMs = 0 +let wslDistroListEmptyStreak = 0 +let wslDistroProbeSequence = 0 +let wslDistroCacheSequence = 0 +// Why: availability is a separate, blocking probe. Deliberately not a multiple of the +// renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and +// re-probe every cycle. +const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 -function normalizeWslListOutput(output: string): string[] { - // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through - // some Windows shells; strip them before line parsing. - return output - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim().replace(/^\*\s*/, '')) - .filter(Boolean) +function armWslDistroListRetry(): void { + const now = Date.now() + // Concurrent completions belong to the retry window already armed by the first result. + if (now < wslDistroListRetryAfterMs) { + return + } + wslDistroListEmptyStreak += 1 + wslDistroListRetryAfterMs = now + wslDistroListRetryDelayMs(wslDistroListEmptyStreak) } -function isUserWslDistro(distro: string): boolean { - return !distro.toLowerCase().startsWith('docker-desktop') +// Why: `wsl --install` reports zero distros while one is still provisioning, so an +// empty answer is transient and must stay retryable — caching it for the process +// lifetime is what made WSL vanish from the picker after setup. It is still cached +// for reads, so a missing distro stays visible to `isKnownMissingDistro`. +function cacheWslDistroList(rawDistros: string[], probeSequence: number): string[] { + const userDistros = filterUserWslDistros(rawDistros) + // An older positive result must not replace the newer lifetime-stable list. + if ( + probeSequence < wslDistroCacheSequence && + (userDistros.length === 0 || (wslDistroCache?.length ?? 0) > 0) + ) { + return wslDistroCache ?? [] + } + // Why: probes overlap and can resolve out of order — a slow pre-registration wsl.exe + // can land after a fast one that already found the distro. A late empty answer must + // not erase that list, or provisioning reverts to "no distros" and backs off again. + if (userDistros.length === 0 && wslDistroCache !== null && wslDistroCache.length > 0) { + return wslDistroCache + } + if (userDistros.length > 0) { + dropStaleWslAvailabilityFailure() + } + wslDistroCacheSequence = probeSequence + wslDistroCache = userDistros + if (wslDistroCache.length === 0) { + armWslDistroListRetry() + } + return wslDistroCache +} + +/** A non-empty list is stable; an empty one re-probes once the retry window elapses. */ +function shouldReuseCachedWslDistros(): boolean { + return ( + wslDistroCache !== null && (wslDistroCache.length > 0 || Date.now() < wslDistroListRetryAfterMs) + ) } export function listWslDistros(): string[] { - if (wslDistroCache) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -126,27 +168,27 @@ export function listWslDistros(): string[] { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } export async function listWslDistrosAsync(): Promise { - if (wslDistroCache !== null) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -154,17 +196,17 @@ export async function listWslDistrosAsync(): Promise { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = await execFileUtf8('wsl.exe', ['--list', '--quiet']) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } @@ -172,6 +214,11 @@ export function hasCachedWslDistros(): boolean { return wslDistroCache !== null } +// Why: report the last observed answer even once it is stale. An empty list is a real +// probe result, so it must keep driving the `wsl-distro-missing` repair prompt; going +// null instead fails open and silently spawns `wsl.exe -d ` for a distro Orca +// last saw was absent. Staleness self-corrects — `listWslDistros` re-probes after the +// retry window and a distro installed since clears the prompt on its own. export function getCachedWslDistros(): string[] | null { return wslDistroCache } @@ -234,20 +281,82 @@ export async function getWslHomeAsync(distro: string): Promise { } } -// Cached WSL availability check — evaluated once per process lifetime -let wslAvailableCache: boolean | null = null +type WslAvailabilityCache = + | { available: true } + /** Not Windows — never re-probed. */ + | { available: false; unsupported: true } + | { available: false; cachedAt: number; retryable: boolean; failures: number } + +function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { + // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. + return cache.available || ('unsupported' in cache && process.platform !== 'win32') +} + +let wslAvailableCache: WslAvailabilityCache | null = null + +// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one +// while the WSL package is servicing or LxssManager is still starting — so nothing +// latches for the whole session; it just waits much longer before paying the probe again. +const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 +const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 + +// Why: the probe blocks the main process for up to 5s, so a host with a wedged wsl.exe +// must not pay it every window; back off per consecutive failure. +function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { + const base = cache.retryable + ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS + : WSL_AVAILABILITY_DEFINITIVE_TTL_MS + return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) +} + +// Why: a numeric `status` (wsl.exe ran and said no) or ENOENT (not installed) is +// answer-shaped, so it earns a long window rather than the short one a timeout gets. +// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. +function isRetryableWslProbeFailure(error: unknown): boolean { + const failure = error as { status?: unknown; code?: unknown } | null + if (typeof failure?.status === 'number') { + return false + } + return failure?.code !== 'ENOENT' +} + +// Why: the two caches expire independently, and `getWslRepairReason` checks availability +// first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a +// WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the +// stale failure and let the next call re-probe. Non-empty lists are cached for the process +// lifetime, so this cannot re-spawn the blocking probe more than once. +function dropStaleWslAvailabilityFailure(): void { + if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { + wslAvailableCache = null + } +} + +function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { + if (isPermanentWslAvailabilityCache(cache)) { + return true + } + if (!('cachedAt' in cache)) { + return false + } + return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache) +} /** * Check whether wsl.exe is available and functional on this Windows machine. - * Result is cached for the process lifetime. + * Success caches for the process lifetime; every failure is re-probed eventually, so + * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch + * WSL off for the whole session. */ export function isWslAvailable(): boolean { - if (wslAvailableCache !== null) { - return wslAvailableCache + if (wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)) { + return wslAvailableCache.available } + const previousFailures = + wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 + if (process.platform !== 'win32') { - wslAvailableCache = false + wslAvailableCache = { available: false, unsupported: true } return false } @@ -256,35 +365,70 @@ export function isWslAvailable(): boolean { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslAvailableCache = true - } catch { - wslAvailableCache = false + wslAvailableCache = { available: true } + } catch (error) { + wslAvailableCache = { + available: false, + cachedAt: Date.now(), + retryable: isRetryableWslProbeFailure(error), + failures: previousFailures + 1 + } } - return wslAvailableCache + return wslAvailableCache.available } export function hasCachedWslAvailability(): boolean { return wslAvailableCache !== null } +// Why: same contract as the distro getter — report the last observed answer. Going +// null on staleness would drop the `wsl-unavailable` repair prompt and let git and +// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what +// clears it, by re-probing once the retry window lapses. export function getCachedWslAvailability(): boolean | null { - return wslAvailableCache + return wslAvailableCache?.available ?? null } export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null - wslDistroListFailedUntilMs = 0 + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 wslAvailableCache = null } +// Why: seeded state expires like real state — an `available: false` seed is re-probed +// once its window lapses, and a `distros` seed is filtered and arms the retry window. +// Tests that advance timers past either window must mock child_process. export function _setWslCachesForTests(args: { available?: boolean | null distros?: string[] | null + availabilityRetryable?: boolean }): void { - wslAvailableCache = args.available ?? null - wslDistroCache = args.distros ?? null + wslAvailableCache = + args.available === true + ? { available: true } + : args.available === false + ? { + available: false, + cachedAt: Date.now(), + retryable: args.availabilityRetryable ?? false, + failures: 1 + } + : null + // Why: seed through the real cache path so an empty seed arms the retry window + // too — otherwise a seeded [] lets the next call spawn a real 5s wsl.exe. + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 + wslDistroCache = null + if (args.distros) { + cacheWslDistroList(args.distros, ++wslDistroProbeSequence) + } } function execFileUtf8(command: string, args: string[]): Promise { diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 713ddfb7902..7cbd47bfdcb 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -198,6 +198,24 @@ describe('windows terminal capabilities', () => { expect(wslIsAvailable).toHaveBeenCalledTimes(2) }) + it('rechecks availability when distro discovery invalidates a stale failure', async () => { + const wslIsAvailable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + } + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({ + wslAvailable: true, + wslDistros: ['Ubuntu'] + }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + }) + it('re-probes when the capability cache expires', async () => { const wslIsAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) const pwshIsAvailable = vi.fn().mockResolvedValue(false) @@ -558,6 +576,49 @@ describe('windows terminal capabilities', () => { expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) }) + it('refreshes local capabilities while a long-lived consumer remains mounted', async () => { + vi.useFakeTimers() + const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ + wslAvailable: false, + pwshAvailable: true, + wslDistros: [] + }) + wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) + wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) + let latest: ReturnType | null = null + + function HookProbe(): null { + latest = useWindowsTerminalCapabilities(true) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + try { + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: false, wslDistros: [] }) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: true, wslDistros: ['Ubuntu'] }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(30_000) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it.each([ { name: 'Windows to Linux', diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 37c888f41dc..5d085cc8136 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -174,7 +174,7 @@ export function loadWindowsTerminalCapabilities( } export function refreshWindowsTerminalCapabilities( - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): Promise { @@ -197,7 +197,7 @@ export function selectWindowsTerminalCapabilitiesForOwner( export function useWindowsTerminalCapabilities( enabled: boolean, forceRefreshOnMount = false, - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): WindowsTerminalCapabilities { @@ -249,9 +249,24 @@ export function useWindowsTerminalCapabilities( setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) } }) + const refreshInterval = globalThis.setInterval(() => { + if (resolvedTarget.kind !== 'local' || sshConnectionIdKey) { + return + } + const cachedCapabilities = getCachedWindowsTerminalCapabilities(resolvedOwnerKey) + if (cachedCapabilities.wslAvailable && cachedCapabilities.wslDistros.length > 0) { + return + } + void loadWindowsTerminalCapabilities({ + ownerKey: resolvedOwnerKey, + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey + }) + }, CAPABILITY_CACHE_TTL_MS) return () => { cancelled = true + globalThis.clearInterval(refreshInterval) const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey) currentSubscribers?.delete(setCapabilities) if (currentSubscribers?.size === 0) { diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index e73336b424f..3c9a7edc6bc 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -4,6 +4,18 @@ import type { WindowsTerminalCapabilities } from './windows-terminal-capabilitie export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget +async function reconcileWslAvailability( + available: boolean, + distros: string[], + probe: () => Promise +): Promise { + // A non-empty list can invalidate the negative cache after the parallel probe read it. + if (available || distros.length === 0) { + return available + } + return probe().catch(() => false) +} + export async function readWindowsTerminalCapabilities( target: WindowsTerminalCapabilityLoadTarget, sshConnectionId?: string | null @@ -48,8 +60,11 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + window.api.wsl.isAvailable() + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, @@ -76,8 +91,13 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { + timeoutMs: 15_000 + }) + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, From 8e9dc4ecf2899ebd59a93ed10adb6b44caf0ddf1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:55:54 -0700 Subject: [PATCH 021/123] [P2] fix(persistence): tolerate null worktreeMeta entries in linked-item normalization (#11614) * fix(persistence): tolerate null worktreeMeta entries in linked-item normalization Co-authored-by: Orca * fix(persistence): drop null worktreeMeta entries instead of retaining them gcStaleWorktreeMeta never reaps a timestamp-less key, so a skipped null meta would persist forever and reach unguarded consumers (reassignSshTargetId, push-target cleanup, disconnected-SSH listing, usage metadata). Co-authored-by: Orca * fix(persistence): drop every corrupt worktreeMeta shape with its lineage rows Widen the guard past null so scalars/arrays can't survive load into a Record that consumers trust, self-heal a whole-map "worktreeMeta": null instead of leaning on gcStaleWorktreeMeta running next, and clear the two lineage companions the way gcStaleWorktreeMeta and removeWorktreeMeta already do. Co-authored-by: Orca * fix(persistence): persist whole-map metadata repairs --------- Co-authored-by: Orca --- src/main/persistence.test.ts | 59 ++++++++++++++++++++++++++++++++++-- src/main/persistence.ts | 22 +++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d7a8c5dd802..5725cc0319d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4930,6 +4930,59 @@ describe('Store', () => { expect(store.getWorktreeMeta('wt-malformed')?.linkedTaskSourceContext).toBeNull() }) + it('drops corrupt worktreeMeta entries while still normalizing valid siblings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeLineageById: { 'r1::/tmp/wt': { parentWorktreeId: 'wt-sibling' } }, + workspaceLineageByChildKey: { + 'worktree:r1::/tmp/wt': { parentWorkspaceKey: 'worktree:wt-sibling' } + }, + worktreeMeta: { + 'r1::/tmp/wt': null, + 'r1::/tmp/scalar': 5, + 'wt-sibling': { + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + }, + linkedTaskSourceContext: { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'local', + accountLabel: 44, + providerIdentity: { + provider: 'jira', + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + } + } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta('wt-sibling')?.linkedWorkItem?.jiraIdentifier).toBe('ORCA-123') + expect(store.getWorktreeMeta('wt-sibling')?.linkedTaskSourceContext).toBeNull() + // Corrupt entries must not survive: gcStaleWorktreeMeta keeps timestamp-less keys, and downstream + // consumers deref worktreeMeta values unguarded (also keeps a rollback to an older build loadable). + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/wt') + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/scalar') + expect(store.getWorktreeLineage('r1::/tmp/wt')).toBeUndefined() + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/wt') + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/scalar') + expect(persisted.workspaceLineageByChildKey).not.toHaveProperty('worktree:r1::/tmp/wt') + }) + it('creates and updates folder workspaces from folder-backed project groups', async () => { const store = await createStore() const group = store.createProjectGroup({ @@ -6046,10 +6099,12 @@ describe('Store', () => { }) }) - it('tolerates a null worktreeMeta map in the durable file', async () => { - writeDataFile({ worktreeMeta: null }) + it.each([null, [], 5])('repairs a corrupt worktreeMeta map (%#)', async (worktreeMeta) => { + writeDataFile({ worktreeMeta }) const store = await createStore() expect(store.getAllWorktreeMeta()).toEqual({}) + store.flush() + expect((readDataFile() as PersistedState).worktreeMeta).toEqual({}) }) // ── GitHub cache sidecar ─────────────────────────────────────────── diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 85de59a8bda..310916115e5 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -425,7 +425,27 @@ function gcStaleWorktreeMeta(state: PersistedState): number { function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { let changed = false - for (const meta of Object.values(state.worktreeMeta ?? {})) { + const rawWorktreeMeta = state.worktreeMeta as unknown + if ( + typeof rawWorktreeMeta !== 'object' || + rawWorktreeMeta === null || + Array.isArray(rawWorktreeMeta) + ) { + state.worktreeMeta = {} + changed = rawWorktreeMeta !== undefined + } + for (const [key, meta] of Object.entries(state.worktreeMeta)) { + // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta + // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + delete state.worktreeMeta[key] + // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would + // otherwise re-attach to a worktree recreated at the same repoId::path. + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + changed = true + continue + } const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( From 6442a9f6496bdecc67716b4aaa1f860ac99cf7d5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:56:58 -0700 Subject: [PATCH 022/123] fix(persistence): backfill the jira-issue workspace-card property for upgraded profiles (#11618) Co-authored-by: Orca --- src/main/persistence.test.ts | 110 +++++++++++++++++- src/main/persistence.ts | 15 ++- .../methods/ui-state-schema-parity-checks.ts | 1 + src/shared/types.ts | 2 + 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 5725cc0319d..ba69a92c524 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -7251,6 +7251,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7287,6 +7288,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7347,6 +7349,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'ports', 'inline-agents' @@ -7363,7 +7366,8 @@ describe('Store', () => { ui: { worktreeCardProperties: ['status', 'pr'], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7422,7 +7426,8 @@ describe('Store', () => { 'ports' ], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7443,6 +7448,107 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) + it('backfills jira-issue once for profiles stamped before it joined the defaults', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: [ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr', + 'ports', + 'inline-agents' + ], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'jira-issue', + 'pr', + 'ports', + 'inline-agents' + ]) + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('preserves a deliberate jira-issue removal after the backfill has run', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'issue', 'linear-issue', 'pr'], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr' + ]) + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + }) + + it('leaves fresh default profiles with a single jira-issue entry', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('skips the jira-issue backfill when card properties are malformed', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { worktreeCardProperties: 'not-an-array' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + it('uses the compact preset when card properties are missing in compact mode', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 310916115e5..e3c0fa329af 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3304,6 +3304,8 @@ export class Store { const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = + parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true const hadExperimentOn = readDeprecatedExperimentFlag(parsed) const deliberateUncheck = hadExperimentOn && @@ -3342,7 +3344,12 @@ export class Store { } return next })() - const normalized = normalizeWorktreeCardProperties(expandedCandidate) + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) const changed = normalized.length !== rawCardProps.length || normalized.some((property, index) => property !== rawCardProps[index]) @@ -3351,7 +3358,8 @@ export class Store { if ( migratedCardProps !== undefined || !inlineAgentsMigrated || - !expandedCardPropsMigrated + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted ) { this.loadNeedsSave = true } @@ -3419,7 +3427,8 @@ export class Store { // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. _inlineAgentsDefaultedForExperiment: true, _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true } })(), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index bbdece031d0..a1ccf86c254 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -11,6 +11,7 @@ type MainOwnedUIState = | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' + | '_jiraIssueWorktreeCardPropertyDefaulted' | 'starNagBaselineAgents' | 'starNagAppVersion' | 'starNagNextThreshold' diff --git a/src/shared/types.ts b/src/shared/types.ts index a4018f8ab86..5e61921779d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3423,6 +3423,8 @@ export type PersistedUIState = { _inlineAgentsDefaultedForAllUsers?: boolean /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ _expandedWorktreeCardPropertiesDefaulted?: boolean + /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ + _jiraIssueWorktreeCardPropertyDefaulted?: boolean /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ starNagBaselineAgents?: number | null /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ From eb35c7fa3e24c463d9b9f55643cf34a2dc6d0ea2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:58:09 -0700 Subject: [PATCH 023/123] [P2] fix(runtime): stop broadcasting terminalSideEffects to clients without consumers (#11619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(runtime): stop broadcasting terminalSideEffects to clients without consumers Co-authored-by: Orca * fix(runtime): keep mobile subscribers counted for side-effect availability Excluding phones from the consumer-availability count added a new flip edge (last desktop client leaving a phone-attached host), and the flip's tracker rebuild cancels armed stale-working-title timers — stranding a 'working' spinner on the phone. Availability counts all subscribers again; the broadcast fix stays in the per-listener fan-out skip, now applied inside the delivery callback so live-Set unsubscribe semantics and allocation-free iteration are preserved. Co-authored-by: Orca * fix(runtime): separate mobile title tracking from side-effect scans --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 75 ++++++++++++ src/main/runtime/orca-runtime.ts | 111 +++++++++++------- .../runtime/rpc/methods/client-events.test.ts | 66 +++++++++++ src/main/runtime/rpc/methods/client-events.ts | 13 +- src/shared/terminal-output-side-effects.ts | 23 +++- 5 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 src/main/runtime/rpc/methods/client-events.test.ts diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2fd94df751f..8c41f25690a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8468,6 +8468,81 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) }) + it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { + const runtime = new OrcaRuntimeService(store) + const desktopEvents: RuntimeClientEvent[] = [] + const mobileEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => desktopEvents.push(event)) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + runtime.notifyBranchRenamed(TEST_REPO_ID) + + expect(desktopEvents.map((event) => event.type)).toEqual([ + 'terminalSideEffects', + 'worktreesChanged' + ]) + expect(mobileEvents.map((event) => event.type)).toEqual(['worktreesChanged']) + }) + + it('keeps a phone-only host producing title state without emitting batches to it', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const runtime = new OrcaRuntimeService(store) + const mobileEvents: RuntimeClientEvent[] = [] + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + const unsubscribeDesktop = runtime.onClientEvent(() => {}) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + // The phone is still subscribed: disposing trackers on this edge would cancel + // its armed stale-working-title timer and strand a 'working' spinner (#1437). + unsubscribeDesktop() + + await vi.advanceTimersByTimeAsync(3_000) + + expect(trackerEntries.has(ptyId)).toBe(true) + expect(trackerEntries.get(ptyId)?.commandCodeDetector).toBeNull() + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ title: 'Codex' }) + expect(mobileEvents.some((event) => event.type === 'terminalSideEffects')).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('skips a listener unsubscribed mid-fan-out even with mobile exclusions active', () => { + const runtime = new OrcaRuntimeService(store) + const lateEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent(() => {}, { consumesTerminalSideEffects: false }) + runtime.onClientEvent(() => { + unsubscribeLate() + }) + const unsubscribeLate = runtime.onClientEvent((event) => lateEvents.push(event)) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + + expect(lateEvents).toEqual([]) + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b505b778e4a..1328dc5c6ed 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2725,6 +2725,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. + private terminalSideEffectExcludedClientEventListeners = new Set< + (event: RuntimeClientEvent) => void + >() private nativeChatLaunchDraftResolutionByTabId = new Map< string, NativeChatLaunchDraftResolutionTombstone @@ -4681,15 +4685,26 @@ export class OrcaRuntimeService { } } - onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void { + onClientEvent( + listener: (event: RuntimeClientEvent) => void, + options?: { consumesTerminalSideEffects?: boolean } + ): () => void { this.clientEventListeners.add(listener) + if (options?.consumesTerminalSideEffects === false) { + this.terminalSideEffectExcludedClientEventListeners.add(listener) + } this.refreshTerminalSideEffectConsumerAvailability() return () => { this.clientEventListeners.delete(listener) + this.terminalSideEffectExcludedClientEventListeners.delete(listener) this.refreshTerminalSideEffectConsumerAvailability() } } + private countTerminalSideEffectConsumingClientEventListeners(): number { + return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size + } + getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { const events: RuntimeClientEvent[] = [] const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => @@ -4747,9 +4762,25 @@ export class OrcaRuntimeService { } private emitClientEvent(event: RuntimeClientEvent): void { + // Why: mobile streams discard terminalSideEffects; skip excluded listeners so + // paired phones never receive the per-OSC batch frames over the relay. Filtered + // inside the delivery callback to keep live-Set iteration (a listener that + // unsubscribes mid-fan-out must not be delivered to) and stay allocation-free. + const skipExcluded = + event.type === 'terminalSideEffects' && + this.terminalSideEffectExcludedClientEventListeners.size > 0 // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. - notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') + notifyRuntimeListeners( + this.clientEventListeners, + (listener) => { + if (skipExcluded && this.terminalSideEffectExcludedClientEventListeners.has(listener)) { + return + } + listener(event) + }, + 'client-event' + ) } notifyNativeChatLaunchDraftResolved( @@ -9417,7 +9448,7 @@ export class OrcaRuntimeService { console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) } } - if (this.clientEventListeners.size > 0) { + if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { this.emitClientEvent({ type: 'terminalSideEffects', batch }) } } @@ -9585,33 +9616,23 @@ export class OrcaRuntimeService { this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, - // Why: headless serve still scans command completion to retire agent - // launch authority; other transient facts remain desktop-only. - ...(this.terminalSideEffectConsumerAvailable - ? { - onBell: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) - }, - onPrLink: (link: TerminalGitHubPRLink) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) - }, - // Why: hidden-delivery-gated views never see the bytes, so main - // surfaces DECSET 2031 subscribes as facts; the theme reply is - // still sent by the renderer (query authority stays with the view). - onMode2031Subscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) - }, - // Why: the gated view never sees the withdrawal bytes either, so the - // subscription registry it keeps for theme flips needs this fact to - // stay truthful. - onMode2031Unsubscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) - } - } - : {}) + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + } }, initialTitle !== null ? { initialTitle } : {} ) + tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, @@ -9624,15 +9645,7 @@ export class OrcaRuntimeService { // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. commandCodeDetector: this.terminalSideEffectConsumerAvailable - ? createCommandCodeOutputStatusDetector({ - startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, - onWorking: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) - }, - onDone: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) - } - }) + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) @@ -9752,18 +9765,34 @@ export class OrcaRuntimeService { private refreshTerminalSideEffectConsumerAvailability(): void { const nextAvailable = - this.terminalSideEffectLocalConsumerAvailable || this.clientEventListeners.size > 0 + this.terminalSideEffectLocalConsumerAvailable || + this.countTerminalSideEffectConsumingClientEventListeners() > 0 if (nextAvailable === this.terminalSideEffectConsumerAvailable) { return } this.terminalSideEffectConsumerAvailable = nextAvailable - // Why: optional bell/command/link scanners are selected when a tracker is - // created. Rebuild at the window boundary so pure headless output stays cheap. - for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { - this.disposePtyTitleTracker(ptyId) + for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { + entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) + entry.commandCodeDetector = nextAvailable + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) + : null } } + private createTerminalSideEffectCommandCodeDetector( + ptyId: string + ): NonNullable { + return createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + } + private extractLastOsc7CwdForPty( ptyId: string, data: string diff --git a/src/main/runtime/rpc/methods/client-events.test.ts b/src/main/runtime/rpc/methods/client-events.test.ts new file mode 100644 index 00000000000..bf5026c819e --- /dev/null +++ b/src/main/runtime/rpc/methods/client-events.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core' +// Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it. +import { ALL_RPC_METHODS } from './index' + +const subscribeMethod = ALL_RPC_METHODS.find( + (method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method) +) as RpcStreamingMethod + +function makeRuntime(): { + runtime: OrcaRuntimeService + onClientEvent: ReturnType + cleanups: (() => void)[] +} { + const cleanups: (() => void)[] = [] + const onClientEvent = vi.fn( + ( + _listener: (event: RuntimeClientEvent) => void, + _options?: { consumesTerminalSideEffects?: boolean } + ) => + () => {} + ) + const runtime = { + onClientEvent, + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + cleanups.push(cleanup) + } + } as unknown as OrcaRuntimeService + return { runtime, onClientEvent, cleanups } +} + +describe('runtime.clientEvents.subscribe', () => { + it('registers mobile subscriptions as non-consumers of terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1', clientKind: 'mobile' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: false + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) + + it('keeps non-mobile subscriptions consuming terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: true + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) +}) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 956594dd5e3..81ee32d1988 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -16,11 +16,16 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', params: null, - handler: async (_params, { runtime, connectionId }, emit) => { + handler: async (_params, { runtime, connectionId, clientKind }, emit) => { await new Promise((resolve) => { - const unsubscribe = runtime.onClientEvent((event) => { - emit(event) - }) + // Why: mobile discards terminalSideEffects; excluding it stops the + // per-OSC batch frames from crossing the relay. + const unsubscribe = runtime.onClientEvent( + (event) => { + emit(event) + }, + { consumesTerminalSideEffects: clientKind !== 'mobile' } + ) const seq = ++clientEventSubscriptionSeq const subscriptionId = `runtime-client-events-${connectionId ?? 'inproc'}-${seq}` diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index cde876882c6..a022e2469f2 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -98,6 +98,8 @@ export type TerminalTitleTracker = { * Titles are unaffected; un-suppressing resets the scanners' cross-chunk carry. */ setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Enable consumer-only bell, PR-link, and mode-2031 scans without resetting title state. */ + setTransientSideEffectScanningEnabled: (enabled: boolean) => void /** Cancel the stale-title timer and clear accumulated tracker state. */ dispose: () => void } @@ -117,12 +119,13 @@ export function createTerminalTitleTracker( onMode2031Subscribe, onMode2031Unsubscribe } = callbacks - const bellDetector = onBell ? createBellDetector() : null + let bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. const commandFinishedScanner = onCommandFinished ? createOsc133CommandFinishedScanner(onCommandFinished) : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientSideEffectScanningEnabled = true let transientFactScanningSuppressed = false let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE // Why: seed both so a mid-session tracker behaves as if it had observed the pane's last live title (renderer parity). @@ -210,7 +213,7 @@ export function createTerminalTitleTracker( onPrLink?.(link) } } - if (onMode2031Subscribe || onMode2031Unsubscribe) { + if (transientSideEffectScanningEnabled && (onMode2031Subscribe || onMode2031Unsubscribe)) { const previousMode2031ReplyScanState = options.mode2031PendingSubscribe ? { ...mode2031ReplyScanState, pendingSubscribe: true } : mode2031ReplyScanState @@ -238,7 +241,11 @@ export function createTerminalTitleTracker( } } // The permission BEL rides outside the OSC title; a FRESH detector avoids touching the chunk detector's cross-chunk escape state. - if (onBell && createBellDetector().chunkContainsBell(frame)) { + if ( + transientSideEffectScanningEnabled && + onBell && + createBellDetector().chunkContainsBell(frame) + ) { onBell() } // Why: deliberately skip the 133/PR-link/2031 scanners — fabricated bytes contain none and must not perturb their cross-chunk carry. @@ -271,6 +278,16 @@ export function createTerminalTitleTracker( } } }, + setTransientSideEffectScanningEnabled(enabled: boolean): void { + if (enabled === transientSideEffectScanningEnabled) { + return + } + transientSideEffectScanningEnabled = enabled + bellDetector?.reset() + bellDetector = enabled && onBell ? createBellDetector() : null + prLinkDetector = enabled && onPrLink ? createTerminalGitHubPRLinkDetector() : null + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE + }, dispose(): void { clearStaleTitleTimer() agentTracker?.reset() From b370dc09001d6aeb40e0864a5c1f997da9cb8956 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:08:30 -0700 Subject: [PATCH 024/123] ci(release-cut): include source ref/commit and cutter in SignPath Slack (#10524) --- .github/workflows/release-cut.yml | 63 ++++++++++++++++++- .../release-cut-signpath-slack.test.mjs | 36 +++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 config/scripts/release-cut-signpath-slack.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 7cb1e055e7e..39d170f01e1 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -72,6 +72,11 @@ jobs: tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }} + # Why: downstream SignPath Slack pings need the cut source (not only the + # new tag) so approvers know what they're signing and who cut it. + source_ref: ${{ steps.resolve.outputs.ref }} + source_sha: ${{ steps.resolve.outputs.sha }} + source_short_sha: ${{ steps.resolve.outputs.short_sha }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -122,9 +127,12 @@ jobs: SERVER_URL: ${{ github.server_url }} run: | set -euo pipefail + input_ref="${INPUT_REF:-main}" sha="$(git rev-parse HEAD)" short_sha="$(git rev-parse --short=12 HEAD)" + echo "ref=$input_ref" >>"$GITHUB_OUTPUT" echo "sha=$sha" >>"$GITHUB_OUTPUT" + echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT" # Why: only push the version-bump commit back to main when the # caller is releasing the exact tip of main. For any older or @@ -142,7 +150,6 @@ jobs: # `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA # inputs it also hides the human-readable names operators need # when auditing RC cuts. - input_ref="${INPUT_REF:-main}" repo_url="${SERVER_URL}/${REPO}" branches="$( @@ -1339,6 +1346,12 @@ jobs: SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { @@ -1350,7 +1363,26 @@ jobs: $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } - $message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @@ -1523,6 +1555,12 @@ jobs: SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }} run: | @@ -1537,7 +1575,26 @@ jobs: # Why: releases where inner signing fell through have only this one request. $stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' } - $message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( diff --git a/config/scripts/release-cut-signpath-slack.test.mjs b/config/scripts/release-cut-signpath-slack.test.mjs new file mode 100644 index 00000000000..0795ac8895a --- /dev/null +++ b/config/scripts/release-cut-signpath-slack.test.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('release-cut SignPath Slack approval pings', () => { + it('includes cut source ref/commit and who triggered the cut', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + + const cutOutputs = workflow.jobs.cut.outputs + expect(cutOutputs.source_ref).toContain('steps.resolve.outputs.ref') + expect(cutOutputs.source_sha).toContain('steps.resolve.outputs.sha') + expect(cutOutputs.source_short_sha).toContain('steps.resolve.outputs.short_sha') + + const steps = workflow.jobs.build.steps + const notifySteps = steps.filter( + (step) => + step.name === 'Notify Slack that inner-binary signing is waiting for approval' || + step.name === 'Notify Slack that Windows signing is waiting for approval' + ) + expect(notifySteps).toHaveLength(2) + + for (const step of notifySteps) { + expect(step.env.SOURCE_REF).toContain('needs.cut.outputs.source_ref') + expect(step.env.SOURCE_SHA).toContain('needs.cut.outputs.source_sha') + expect(step.env.SOURCE_SHORT_SHA).toContain('needs.cut.outputs.source_short_sha') + expect(step.env.CUT_BY).toMatch(/github\.(triggering_actor|actor)/) + expect(step.run).toContain('Source:') + expect(step.run).toContain('cut by') + } + }) +}) From d35ee5c38bb7334b9853ffe39477cb496ec14ea3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:08:51 -0700 Subject: [PATCH 025/123] test(settings): correct stale coverage note on the remote-scope test (#10733) Co-authored-by: Orca --- src/renderer/src/components/settings/AccountsPane.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d9b5cd182c0..59dd184bab1 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -103,8 +103,8 @@ describe('AccountsPane', () => { it('scopes account copy to the active remote server and disables local sign-in actions', () => { // Note: static SSR markup reads the store's initial state (zustand v5), so - // this exercises the fallback server label; the named-server path is - // covered by live validation against a paired server. + // this exercises the pre-hydration path where no server name is known yet. + // The named-server label is covered in provider-account-scope.test.ts. const markup = renderPane( { ...getDefaultSettings('/tmp'), From a906f98baf1d57102731eeb481444928f8826ff9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:37:14 -0700 Subject: [PATCH 026/123] fix(release): survive PSGallery outages in the Windows signing preflight The Windows release job hard-failed in run 30125672117: every SignPath module install attempt got 403 Forbidden from the gallery's OData API, which is behind Azure Front Door and was also serving 502/504 at the time. That step was the only hard-fail in an otherwise fail-open signing chain, so a gallery incident blocked the whole release. The gallery CDN that serves the nupkg is a separate origin and stayed healthy throughout, so fall back to a pinned version fetched from it after the normal install path is exhausted. The fallback verifies a SHA-256 pin, since that route skips the gallery's own package validation. Extracted to a composite action so the release job and the signing rehearsal cannot drift apart. --- .../install-signpath-module/action.yml | 197 ++++++++++++++++++ .github/workflows/release-cut.yml | 77 +------ .../workflows/windows-signing-rehearsal.yml | 18 +- ...package-electron-runtime-contract.test.mjs | 110 ---------- ...windows-signing-workflow-contract.test.mjs | 161 ++++++++++++++ 5 files changed, 360 insertions(+), 203 deletions(-) create mode 100644 .github/actions/install-signpath-module/action.yml create mode 100644 config/scripts/windows-signing-workflow-contract.test.mjs diff --git a/.github/actions/install-signpath-module/action.yml b/.github/actions/install-signpath-module/action.yml new file mode 100644 index 00000000000..3f41daca3ab --- /dev/null +++ b/.github/actions/install-signpath-module/action.yml @@ -0,0 +1,197 @@ +name: Install SignPath PowerShell module +description: >- + Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery, + falling back to a pinned, hash-verified nupkg from the gallery CDN when the + gallery's package API is unavailable. + +inputs: + fallback-version: + description: Module version fetched directly from the CDN when the gallery API is unreachable. + required: false + default: 4.4.6 + fallback-sha256: + description: >- + SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own + package validation, so this hash is the only integrity check on that route. + required: false + default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d + +runs: + using: composite + steps: + - name: Install SignPath PowerShell module + shell: pwsh + env: + SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }} + SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }} + run: | + $ErrorActionPreference = 'Stop' + # Why: force TLS 1.2 so gallery downloads work on older hosted images. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + # Why: on some hosted Windows images `Register-PSRepository -Default` + # fails inside the legacy nuget.exe provider with "Missing option value + # for: '-source'", so PSGallery is never registered and the install + # below dies with "No repository with the name 'PSGallery'". PSResourceGet + # (bundled with PowerShell 7.4+) has PSGallery registered by default and + # avoids that code path, so prefer it and fall back to PowerShellGet only + # when it is absent. + $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + + try { + if ($useResourceGet) { + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery -Trusted + } else { + Set-PSResourceRepository -Name PSGallery -Trusted + } + } else { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } + } catch { + # Why: repository registration also talks to the gallery, so a gallery + # outage can fail here before a single install is attempted. The CDN + # fallback below does not need a registered repository, so keep going. + Write-Warning "PSGallery repository registration failed: $_" + } + + $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) + $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | + Where-Object { + if ([string]::IsNullOrWhiteSpace($_)) { + $false + } else { + $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) + $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) + } + } | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { + throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' + } + + $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' + + function Test-SignPathModule { + Import-Module SignPath -ErrorAction Stop + Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + } + + function Remove-SignPathModuleDirectory { + if (Test-Path -LiteralPath $signPathModulePath) { + Write-Warning "Removing current-user SignPath module directory: $signPathModulePath" + Remove-Item -LiteralPath $signPathModulePath -Recurse -Force + } + } + + $installed = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -eq 2) { + Start-Sleep -Seconds 15 + } elseif ($attempt -eq 3) { + Start-Sleep -Seconds 30 + } + + try { + if ($useResourceGet) { + Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop + } else { + Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + Test-SignPathModule + $installed = $true + break + } catch { + Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" + Remove-SignPathModuleDirectory + } + } + + # Why: the gallery's package API (OData search + repository metadata) sits + # behind Azure Front Door and has returned 403/502/504 for every install + # attempt during gallery incidents, which hard-failed the whole Windows + # release job. The CDN that serves the nupkg itself is a separate origin + # and stays up through those incidents, so fall back to a pinned version + # fetched straight from it. The hash pin is mandatory: this route skips the + # gallery's package validation, so an unexpected payload must fail loudly. + if (-not $installed) { + $version = $env:SIGNPATH_FALLBACK_VERSION + $expectedHash = $env:SIGNPATH_FALLBACK_SHA256 + Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN." + + $nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + + # Why two URLs: the /api/v2/package route 302s to the CDN and can serve + # while the OData search endpoint is failing; the CDN URL is the same + # redirect target reached directly when the api host is down entirely. + $sources = @( + "https://www.powershellgallery.com/api/v2/package/SignPath/$version", + "https://cdn.powershellgallery.com/packages/signpath.$version.nupkg" + ) + + $downloaded = $false + foreach ($source in $sources) { + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -gt 1) { + Start-Sleep -Seconds (10 * $attempt) + } + + try { + Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop + $actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash.ToUpperInvariant()) { + throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)." + } + $downloaded = $true + Write-Host "Downloaded and verified SignPath $version from $source" + break + } catch { + Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + } + } + + if ($downloaded) { + break + } + } + + if (-not $downloaded) { + throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source." + } + + Remove-SignPathModuleDirectory + # Why a version-named subdirectory: PowerShell only treats a nested folder + # as a side-by-side module version when the name matches the manifest's + # ModuleVersion, which is what makes `Import-Module SignPath` resolve it. + $versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version + New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null + Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force + + # Why: strip nupkg packaging entries so only the module files remain. + foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) { + $path = Join-Path -Path $versionRoot -ChildPath $entry + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + + $manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1' + if (-not (Test-Path -LiteralPath $manifest)) { + throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot." + } + + Test-SignPathModule + } diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 39d170f01e1..1840514e5fd 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1183,82 +1183,7 @@ jobs: - name: Install SignPath PowerShell module if: matrix.platform == 'win' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # Why: force TLS 1.2 so gallery downloads work on older hosted images. - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - - # Why: on some hosted Windows images `Register-PSRepository -Default` - # fails inside the legacy nuget.exe provider with "Missing option value - # for: '-source'", so PSGallery is never registered and the install - # below dies with "No repository with the name 'PSGallery'". PSResourceGet - # (bundled with PowerShell 7.4+) has PSGallery registered by default and - # avoids that code path, so prefer it and fall back to PowerShellGet only - # when it is absent. - $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) - - if ($useResourceGet) { - if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSResourceRepository -PSGallery -Trusted - } else { - Set-PSResourceRepository -Name PSGallery -Trusted - } - } else { - Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null - if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSRepository -Default -InstallationPolicy Trusted - } - Set-PSRepository -Name PSGallery -InstallationPolicy Trusted - } - - $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) - $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | - Where-Object { - if ([string]::IsNullOrWhiteSpace($_)) { - $false - } else { - $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) - $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) - } - } | - Select-Object -First 1 - - if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { - throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' - } - - $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' - - for ($attempt = 1; $attempt -le 3; $attempt++) { - if ($attempt -eq 2) { - Start-Sleep -Seconds 15 - } elseif ($attempt -eq 3) { - Start-Sleep -Seconds 30 - } - - try { - if ($useResourceGet) { - Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop - } else { - Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - } - Import-Module SignPath -ErrorAction Stop - Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop - break - } catch { - if ($attempt -eq 3) { - throw - } - - Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" - if (Test-Path -LiteralPath $signPathModulePath) { - Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath" - Remove-Item -LiteralPath $signPathModulePath -Recurse -Force - } - } - } + uses: ./.github/actions/install-signpath-module # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 73d66e466ec..508ac85575b 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -141,23 +141,7 @@ jobs: if-no-files-found: error - name: Install SignPath PowerShell module - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) - if ($useResourceGet) { - if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSResourceRepository -PSGallery -Trusted - } else { - Set-PSResourceRepository -Name PSGallery -Trusted - } - Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop - } else { - Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - } - Import-Module SignPath -ErrorAction Stop - Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + uses: ./.github/actions/install-signpath-module - name: Submit inner binaries signing request id: submit-inner-signing diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 74ad245ec77..516aac21a23 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => { expect(releaseMacWorkflowText).not.toContain('SIGNPATH_') }) - it('preflights SignPath module install before Windows signing side effects', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const installStepIndexes = stepNames.flatMap((name, index) => - name === 'Install SignPath PowerShell module' ? [index] : [] - ) - const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') - const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') - const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) - expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') - - const uploadThroughDownloadScript = steps - .slice(uploadIndex, downloadIndex + 1) - .map((step) => step.run ?? '') - .join('\n') - - expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') - - const installStep = steps[installStepIndexes[0]] - const installRun = installStep.run - const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( - ([, seconds]) => seconds - ) - - expect(installStep.if).toBe("matrix.platform == 'win'") - expect(installStep.shell).toBe('pwsh') - expect(installRun).toContain( - 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' - ) - expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') - expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') - expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) - expect(installRun).toContain( - "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" - ) - expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) - expect(sleepSeconds).toEqual(['15', '30']) - expect(installRun).toContain( - 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' - ) - expect(installRun).toContain('Import-Module SignPath') - expect(installRun).toContain( - 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' - ) - expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') - expect(installRun).not.toContain('SignPath*') - expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan( - installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath') - ) - expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/) - expect(installRun).not.toMatch(/throw\s+\$_/) - }) - - it('verifies Windows inner binary signatures fail-open before publishing', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') - const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') - const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') - const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') - - expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) - expect(evidenceIndex).toBe(innerVerifyIndex + 1) - expect(publishIndex).toBe(evidenceIndex + 1) - - // Why fail-open: unsigned inner binaries must warn, not block, until the - // flow is proven on a real release (issue #7785). Flip this to 'true' - // together with the workflow env to make the gate required. - expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false') - - // Why: every step in the inner-signing chain must be unable to fail the - // release — a SignPath outage or timeout falls through to today's - // unsigned-inner flow instead of blocking the cut. - const innerChainStepNames = [ - 'Stage unsigned inner PE files for signing', - 'Upload unsigned inner binaries for SignPath', - 'Submit inner binaries signing request', - 'Notify Slack that inner-binary signing is waiting for approval', - 'Download signed inner binaries from SignPath', - 'Restore signed inner binaries into unpacked app', - 'Replace cached elevate.exe with the signed copy', - 'Rebuild NSIS installer from signed unpacked app' - ] - for (const stepName of innerChainStepNames) { - const step = steps[stepNames.indexOf(stepName)] - expect(step, stepName).toBeDefined() - expect(step['continue-on-error'], stepName).toBe(true) - } - }) - it('publishes both Linux release matrix entries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs new file mode 100644 index 00000000000..fc2087379f8 --- /dev/null +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -0,0 +1,161 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) + +describe('Windows signing workflow contract', () => { + it('preflights SignPath module install before Windows signing side effects', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const installStepIndexes = stepNames.flatMap((name, index) => + name === 'Install SignPath PowerShell module' ? [index] : [] + ) + const buildIndex = stepNames.indexOf('Build Windows release artifacts') + const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') + const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') + const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') + + expect(verifyNodePtyIndex).toBe(buildIndex + 1) + expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) + expect(installStepIndexes[0]).toBeLessThan(uploadIndex) + + expect(steps[verifyNodePtyIndex].run).toContain( + 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' + ) + expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') + + const uploadThroughDownloadScript = steps + .slice(uploadIndex, downloadIndex + 1) + .map((step) => step.run ?? '') + .join('\n') + + expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') + + const installStep = steps[installStepIndexes[0]] + + expect(installStep.if).toBe("matrix.platform == 'win'") + expect(installStep.uses).toBe('./.github/actions/install-signpath-module') + expect(installStep.run).toBeUndefined() + + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const actionStep = installAction.runs.steps[0] + const installRun = actionStep.run + const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( + ([, seconds]) => seconds + ) + + expect(installAction.runs.using).toBe('composite') + expect(actionStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') + expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') + expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) + expect(installRun).toContain( + "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" + ) + expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) + expect(sleepSeconds).toContain('15') + expect(sleepSeconds).toContain('30') + expect(installRun).toContain( + 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' + ) + expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop') + expect(installRun).toContain( + 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' + ) + expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') + expect(installRun).not.toContain('SignPath*') + expect(installRun).not.toMatch(/throw\s+\$_/) + }) + + it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => { + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const installRun = installAction.runs.steps[0].run + + // Why: the gallery API 403s during Azure Front Door incidents while its CDN + // stays up, so a pinned nupkg is the fallback. The hash pin is the only + // integrity check on that route — losing it would let any payload install. + const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs + expect(version.default).toMatch(/^4\.\d+\.\d+$/) + expect(sha256.default).toMatch(/^[0-9a-f]{64}$/) + expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256') + expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') + expect(installRun).toContain('throw "SHA-256 mismatch for $source') + expect(installRun).toContain( + 'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg' + ) + + // The module only resolves by name when the folder matches its ModuleVersion. + expect(installRun).toContain( + '$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version' + ) + // The fallback only runs after the gallery route is exhausted, and still + // fails the job when neither route produced a usable module. + expect(installRun.indexOf('$installed = $true')).toBeLessThan( + installRun.indexOf('if (-not $installed)') + ) + expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') + }) + + it('shares one SignPath module install path between release and rehearsal', () => { + const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') + const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) + const installIndex = stepNames.indexOf('Install SignPath PowerShell module') + + // Why: the rehearsal exists to prove the real signing flow, so it must + // install the module exactly the way the release job does. + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe( + './.github/actions/install-signpath-module' + ) + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined() + expect(installIndex).toBeLessThan( + stepNames.indexOf('Download signed inner binaries from SignPath') + ) + }) + + it('verifies Windows inner binary signatures fail-open before publishing', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') + const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') + const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') + const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') + + expect(outerVerifyIndex).toBeGreaterThan(-1) + expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) + expect(evidenceIndex).toBe(innerVerifyIndex + 1) + expect(publishIndex).toBe(evidenceIndex + 1) + + // Why fail-open: unsigned inner binaries must warn, not block, until the + // flow is proven on a real release (issue #7785). Flip this to 'true' + // together with the workflow env to make the gate required. + expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false') + + // Why: every step in the inner-signing chain must be unable to fail the + // release — a SignPath outage or timeout falls through to today's + // unsigned-inner flow instead of blocking the cut. + const innerChainStepNames = [ + 'Stage unsigned inner PE files for signing', + 'Upload unsigned inner binaries for SignPath', + 'Submit inner binaries signing request', + 'Notify Slack that inner-binary signing is waiting for approval', + 'Download signed inner binaries from SignPath', + 'Restore signed inner binaries into unpacked app', + 'Replace cached elevate.exe with the signed copy', + 'Rebuild NSIS installer from signed unpacked app' + ] + for (const stepName of innerChainStepNames) { + const step = steps[stepNames.indexOf(stepName)] + expect(step, stepName).toBeDefined() + expect(step['continue-on-error'], stepName).toBe(true) + } + }) +}) From 554892b184aa60545f894c3f3b595aaac1b54b0b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:19:48 -0700 Subject: [PATCH 027/123] fix(github): classify all blocking check conclusions (#11071) Co-authored-by: Mubashir Rahim --- src/shared/pr-check-status.test.ts | 4 ++++ src/shared/pr-check-status.ts | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/shared/pr-check-status.test.ts b/src/shared/pr-check-status.test.ts index fc8c8415428..b924b68bc19 100644 --- a/src/shared/pr-check-status.test.ts +++ b/src/shared/pr-check-status.test.ts @@ -33,4 +33,8 @@ describe('provider-neutral check status', () => { expect(derivePRCheckStatusFromRollup([{ state: 'PENDING' }])).toBe('pending') expect(derivePRCheckStatusFromRollup([{ state: 'ERROR' }])).toBe('failure') }) + + it.each(['ERROR', 'STARTUP_FAILURE'])('treats raw %s conclusions as failures', (conclusion) => { + expect(derivePRCheckStatusFromRollup([{ status: 'COMPLETED', conclusion }])).toBe('failure') + }) }) diff --git a/src/shared/pr-check-status.ts b/src/shared/pr-check-status.ts index 184b8da5a62..bf7dd5f085c 100644 --- a/src/shared/pr-check-status.ts +++ b/src/shared/pr-check-status.ts @@ -42,8 +42,10 @@ function normalizeRollupCheck(raw: RawCheckRollup, index: number): PRCheckDetail const state = String(raw.state ?? '').toLowerCase() const conclusion = String(raw.conclusion ?? '').toLowerCase() const normalizedConclusion = - conclusion || - (state === 'failure' || state === 'error' ? 'failure' : state === 'success' ? 'success' : '') + conclusion === 'error' || conclusion === 'startup_failure' + ? 'failure' + : conclusion || + (state === 'failure' || state === 'error' ? 'failure' : state === 'success' ? 'success' : '') const isPending = status === 'queued' || status === 'in_progress' || From 1004c1610398c271a4eed5fde24565f8f7785859 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 18:27:06 -0700 Subject: [PATCH 028/123] fix(remote): replace streams that miss authoritative output (#11607) --- config/reliability-gates.jsonc | 35 ++- .../remote-runtime-terminal-multiplexer.ts | 94 ++++++- ...te-runtime-terminal-stall-recovery.test.ts | 157 +++++++++++- .../runtime/runtime-terminal-stream.test.ts | 57 +++++ ...remote-terminal-probe-gap-recovery.spec.ts | 234 ++++++++++++++++++ 5 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 01efbe01e25..ca094dce75d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3079,6 +3079,7 @@ "paired remote terminal ordinary parking", "paired remote terminal bounded scrollback restore", "stalled paired terminal stream recovery", + "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", "manual server disconnect" @@ -3087,13 +3088,13 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -3101,6 +3102,7 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -3128,12 +3130,17 @@ "src/shared/remote-runtime-shared-control-connection.test.ts", "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts", "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", + "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/terminal-parked-memory.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", + "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] + }, { "file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", "assertions": [ @@ -3231,6 +3238,9 @@ "restarts only the stream whose renderer delivery credit never settles", "probes then restarts a stream when an entered command receives no frames", "keeps a silent responsive stream after its authoritative snapshot probe", + "restarts a stream when the authoritative snapshot advanced without live output", + "establishes a probe baseline before recovering an unsequenced stream", + "keeps a responsive stream when its probe confirms zero output high-water", "classifies a capacity rejection followed by end as recoverable transport pressure" ] }, @@ -3245,6 +3255,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17.3, + "summary": "The headed paired oracle dropped and acknowledged output only for the original client stream, proved the fixture consumed input while the host model advanced and the client stayed stale, then replaced the stream, repainted exact fixture output, preserved authoritative PTY identity and target-tab cardinality, and resumed live I/O." + }, { "date": "2026-07-29", "runner": "local", @@ -3328,7 +3347,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." }, "performanceBudget": { "required": true, @@ -3346,7 +3365,7 @@ "The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.", "Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.", "Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.", - "The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary." + "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], "demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, @@ -6501,6 +6520,12 @@ "malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output" ] }, + { + "file": "src/renderer/src/runtime/runtime-terminal-stream.test.ts", + "assertions": [ + "drops output only from the armed stream when its replacement reuses the stream ID" + ] + }, { "file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "assertions": [ diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5d828b36c89..3b2761504bf 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -110,6 +110,8 @@ type RemoteRuntimeMultiplexedTerminalState = { // Track it so a gap triggers a self-healing snapshot resync instead of // silently rendering corrupt/missing output (frame-drop resync). expectedSeq: number | undefined + // Why: compare command probes when initial/live frames supplied no high-water. + commandProbeBaselineSeq: number | undefined recoverySnapshotSeq: number | undefined resyncInFlight: boolean resyncPendingSend: boolean @@ -162,6 +164,8 @@ export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' type E2eRemoteTerminalMultiplexAckGateSnapshot = { + droppedOutputBytes: number + droppedOutputFrames: number heldTerminalCount: number heldStreamCount: number heldAckChars: number @@ -169,6 +173,7 @@ type E2eRemoteTerminalMultiplexAckGateSnapshot = { } type E2eRemoteTerminalMultiplexAckGateApi = { + dropOutputUntilResubscribe: (terminals: string[]) => number hold: (terminals: string[]) => void release: () => void sendInput: (terminal: string, text: string) => number @@ -180,6 +185,9 @@ type E2eRemoteTerminalMultiplexAckGateWindow = Window & { } const e2eHeldRemoteAckTerminals = new Set() +const e2eDroppedOutputStreams = new Set() +let e2eDroppedOutputBytes = 0 +let e2eDroppedOutputFrames = 0 let e2eReleasedRemoteAckChars = 0 function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { @@ -198,6 +206,8 @@ function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { } } return { + droppedOutputBytes: e2eDroppedOutputBytes, + droppedOutputFrames: e2eDroppedOutputFrames, heldTerminalCount: e2eHeldRemoteAckTerminals.size, heldStreamCount, heldAckChars, @@ -212,19 +222,52 @@ function releaseE2eRemoteTerminalAcks(): void { e2eHeldRemoteAckTerminals.clear() } +function resetE2eDroppedRemoteOutput(): void { + e2eDroppedOutputStreams.clear() + e2eDroppedOutputBytes = 0 + e2eDroppedOutputFrames = 0 +} + +function shouldDropE2eRemoteTerminalOutput( + stream: RemoteRuntimeMultiplexedTerminalState, + bytes: number +): boolean { + if (!e2eConfig.exposeStore || !e2eDroppedOutputStreams.has(stream)) { + return false + } + e2eDroppedOutputBytes += bytes + e2eDroppedOutputFrames += 1 + return true +} + function exposeE2eRemoteTerminalMultiplexAckGate(): void { if (!e2eConfig.exposeStore || typeof window === 'undefined') { return } const target = window as E2eRemoteTerminalMultiplexAckGateWindow target.__remoteTerminalMultiplexAckGate ??= { + dropOutputUntilResubscribe: (terminals) => { + resetE2eDroppedRemoteOutput() + const targets = new Set(terminals) + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (targets.has(stream.terminal)) { + e2eDroppedOutputStreams.add(stream) + } + } + } + return e2eDroppedOutputStreams.size + }, hold: (terminals) => { releaseE2eRemoteTerminalAcks() for (const terminal of terminals) { e2eHeldRemoteAckTerminals.add(terminal) } }, - release: releaseE2eRemoteTerminalAcks, + release: () => { + releaseE2eRemoteTerminalAcks() + resetE2eDroppedRemoteOutput() + }, sendInput: (terminal, value) => { let sent = 0 for (const multiplexer of multiplexers.values()) { @@ -290,6 +333,7 @@ class RemoteRuntimeTerminalMultiplexer { initialSnapshotReceived: false, pendingSnapshotRequest: null, expectedSeq: undefined, + commandProbeBaselineSeq: undefined, recoverySnapshotSeq: undefined, resyncInFlight: false, resyncPendingSend: false, @@ -586,6 +630,14 @@ class RemoteRuntimeTerminalMultiplexer { } return } + if ( + (frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.OutputSpan) && + shouldDropE2eRemoteTerminalOutput(stream, frame.payload.byteLength) + ) { + this.queueOutputAcknowledgement(stream, frame.payload.byteLength) + return + } stream.watchdog.recordInbound() if ( frame.opcode === TerminalStreamOpcode.Output || @@ -644,6 +696,7 @@ class RemoteRuntimeTerminalMultiplexer { } if (typeof seq === 'number') { stream.expectedSeq = seq + stream.commandProbeBaselineSeq = undefined } stream.callbacks.onData(data, { seq, @@ -746,6 +799,7 @@ class RemoteRuntimeTerminalMultiplexer { if (target === 'initial') { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.resyncInFlight = false stream.resyncPendingSend = false stream.initialSnapshotReceived = true @@ -756,6 +810,7 @@ class RemoteRuntimeTerminalMultiplexer { if (snapshotApplied) { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.recoverySnapshotSeq = typeof info?.seq === 'number' ? info.seq : undefined stream.resyncAttempts = 0 stream.resyncInFlight = false @@ -996,13 +1051,47 @@ class RemoteRuntimeTerminalMultiplexer { private probeCommandResponse(stream: RemoteRuntimeMultiplexedTerminalState): void { void this.requestSnapshot(stream).then( - () => { + (snapshot) => { if (this.streams.get(stream.streamId) !== stream) { return } + if (typeof snapshot?.seq === 'number' && typeof stream.expectedSeq !== 'number') { + if ( + typeof stream.commandProbeBaselineSeq === 'number' && + snapshot.seq > stream.commandProbeBaselineSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_baseline_advanced', { + baselineSeq: stream.commandProbeBaselineSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } + stream.commandProbeBaselineSeq ??= snapshot.seq + } else if ( + typeof snapshot?.seq === 'number' && + typeof stream.expectedSeq === 'number' && + snapshot.seq > stream.expectedSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_detected_gap', { + deliveredSeq: stream.expectedSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } stream.watchdog.completeCommandResponseProbe() recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_succeeded', { + deliveredSeq: stream.expectedSeq ?? null, environmentId: this.environmentId, + probeBaselineSeq: stream.commandProbeBaselineSeq ?? null, + snapshotSeq: snapshot?.seq ?? null, streamId: stream.streamId, terminal: stream.terminal }) @@ -1209,6 +1298,7 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() e2eHeldRemoteAckTerminals.clear() + resetE2eDroppedRemoteOutput() e2eReleasedRemoteAckChars = 0 } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts index 921eb0d3a4f..1e8b239d1ce 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts @@ -142,6 +142,7 @@ describe('remote terminal stalled stream recovery', () => { client: { id: 'mac-viewer', type: 'desktop' }, callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } }) + emitSnapshot(stream.streamId, undefined, 'baseline', 8) sendBinary.mockClear() expect(stream.sendInput('secret\r')).toBe(true) @@ -152,7 +153,7 @@ describe('remote terminal stalled stream recovery', () => { : null expect(firstRequestPayload?.requestId).toBeTypeOf('number') - emitRequestedSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0) + emitSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0, 'baseline', 8) await vi.advanceTimersByTimeAsync(0) expect(onTransportClose).not.toHaveBeenCalled() @@ -163,6 +164,138 @@ describe('remote terminal stalled stream recovery', () => { stream.close() }) + it('restarts a stream when the authoritative snapshot advanced without live output', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const onData = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-stale-live-tail', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData, onSnapshot: vi.fn(), onTransportClose } + }) + emitOutput(stream.streamId, 'baseline') + sendBinary.mockClear() + + expect(stream.sendInput('echo missing\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'baselinemissing', 15) + await vi.advanceTimersByTimeAsync(0) + + expect(onData).toHaveBeenCalledOnce() + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('establishes a probe baseline before recovering an unsequenced stream', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-first-output-lost', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('echo first-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const sameRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[1] + const samePayload = sameRequest + ? decodeTerminalStreamJson<{ requestId: number }>(sameRequest.payload) + : null + emitSnapshot(stream.streamId, samePayload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('regressed-snapshot\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const regressedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[2] + expect(regressedRequest).toBeDefined() + if (!regressedRequest) { + throw new Error('Missing regressed snapshot request') + } + const regressedPayload = decodeTerminalStreamJson<{ requestId: number }>( + regressedRequest.payload + ) + expect(regressedPayload?.requestId).toBeTypeOf('number') + if (!regressedPayload || typeof regressedPayload.requestId !== 'number') { + throw new Error('Malformed regressed snapshot request') + } + emitSnapshot(stream.streamId, regressedPayload.requestId, 'partial-output', 8) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('still-below-baseline\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const belowBaselineRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[3] + expect(belowBaselineRequest).toBeDefined() + if (!belowBaselineRequest) { + throw new Error('Missing below-baseline snapshot request') + } + const belowBaselinePayload = decodeTerminalStreamJson<{ requestId: number }>( + belowBaselineRequest.payload + ) + expect(belowBaselinePayload?.requestId).toBeTypeOf('number') + if (!belowBaselinePayload || typeof belowBaselinePayload.requestId !== 'number') { + throw new Error('Malformed below-baseline snapshot request') + } + emitSnapshot(stream.streamId, belowBaselinePayload.requestId, 'partial-output', 10) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('echo second-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const advancedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[4] + const advancedPayload = advancedRequest + ? decodeTerminalStreamJson<{ requestId: number }>(advancedRequest.payload) + : null + emitSnapshot(stream.streamId, advancedPayload?.requestId ?? 0, 'second-output', 25) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('keeps a responsive stream when its probe confirms zero output high-water', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-unsequenced-silent', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, '', 0) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(sentUnsubscribeStreamIds()).toEqual([]) + stream.close() + }) + it('keeps a command stream when any host frame proves it is responsive', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') @@ -240,7 +373,12 @@ describe('remote terminal stalled stream recovery', () => { ) } - function emitRequestedSnapshot(streamId: number, requestId: number): void { + function emitSnapshot( + streamId: number, + requestId: number | undefined, + data = '', + seq?: number + ): void { callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotStart, @@ -250,15 +388,26 @@ describe('remote terminal stalled stream recovery', () => { kind: 'scrollback', requestId, cols: 80, - rows: 24 + rows: 24, + seq }) }) ) + if (data) { + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + } callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotEnd, streamId, - seq: 2, + seq: 3, payload: new Uint8Array() }) ) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index be485d0ef2f..13e6278e91b 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -462,6 +462,63 @@ describe('remote runtime terminal multiplex ACK gate', () => { liveTerminal.close() }) + it('drops output only from the armed stream when its replacement reuses the stream ID', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const oldData = vi.fn() + const oldStream = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-old', type: 'desktop' }, + callbacks: { onData: oldData, onSnapshot: vi.fn() } + }) + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + release: () => void + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate?.dropOutputUntilResubscribe(['terminal-reused-stream-id'])).toBe(1) + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: oldStream.streamId, + seq: 7, + payload: encodeTerminalStreamText('dropped') + }) + ) + expect(oldData).not.toHaveBeenCalled() + oldStream.close() + + const replacementData = vi.fn() + const replacement = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-new', type: 'desktop' }, + callbacks: { onData: replacementData, onSnapshot: vi.fn() } + }) + expect(replacement.streamId).toBe(oldStream.streamId) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: replacement.streamId, + seq: 9, + payload: encodeTerminalStreamText('delivered') + }) + ) + + expect(replacementData).toHaveBeenCalledWith('delivered', { seq: 9, rawLength: 9 }) + gate?.release() + replacement.close() + }) + it('applies mid-session recovery snapshots without re-subscribing', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') diff --git a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts new file mode 100644 index 00000000000..9cd76f0d3e5 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts @@ -0,0 +1,234 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalShow +} from '../../src/shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-probe-gap-')) +const fixturePath = path.join(scratch, 'probe-gap-terminal.mjs') +const processedInputPath = path.join(scratch, 'processed-input.txt') +writeFileSync(processedInputPath, '') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const processedInputPath = process.argv[2]', + "process.stdout.write('PROBE_GAP_READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const commands = pending.split(/\\r\\n|\\r|\\n/)', + ' pending = commands.pop() ?? ""', + ' for (const input of commands) {', + ' appendFileSync(processedInputPath, `${input}\\n`)', + ' process.stdout.write(`LIVE:${input}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath, processedInputPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +test('replaces a stale paired stream when the PTY snapshot advanced @headful', async ({ + electronApp, + orcaPage +}) => { + test.setTimeout(90_000) + const worktreeId = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const id = state?.activeWorktreeId + if (!id || !state?.allWorktrees().some((candidate) => candidate.id === id)) { + throw new Error('Headed host did not select its seeded worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + let terminal: string | null = null + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === id), + worktreeId + ), + { timeout: 30_000 } + ) + .toBe(true) + const created = await callRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(client.page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixtureCommand(), + activate: false, + select: false, + navigation: 'caller' + }) + terminal = created.tab.terminal + if (!terminal) { + throw new Error('Paired runtime did not publish the probe-gap fixture') + } + const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId) + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + const originalPtyId = await waitForActivePanePtyId(client.page, 30_000) + const originalHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(originalHostTerminal.terminal.ptyId).not.toBeNull() + await expect + .poll(() => getTerminalContent(client.page), { timeout: 30_000 }) + .toContain('PROBE_GAP_READY') + const textarea = client.page.locator('.xterm-helper-textarea:visible').first() + await textarea.focus() + + expect( + await client.page.evaluate((target) => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + } + } + ).__remoteTerminalMultiplexAckGate + if (!gate) { + throw new Error('Remote terminal multiplex output gate is unavailable') + } + return gate.dropOutputUntilResubscribe([target]) + }, terminal) + ).toBe(1) + const missingMarker = `PROBE_GAP_MISSING_${Date.now()}` + await client.page.keyboard.type(missingMarker) + await client.page.keyboard.press('Enter') + + await expect + .poll( + () => + client.page.evaluate(() => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + snapshot: () => { droppedOutputFrames: number } + } + } + ).__remoteTerminalMultiplexAckGate + return gate?.snapshot().droppedOutputFrames ?? 0 + }), + { timeout: 10_000 } + ) + .toBeGreaterThan(0) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${missingMarker}\n`) + await expect + .poll( + async () => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + orcaPage, + 'terminal.read', + { terminal } + ) + return result.terminal.tail.join('\n') + }, + { timeout: 10_000 } + ) + .toContain(missingMarker) + expect(await getTerminalContent(client.page)).not.toContain(`LIVE:${missingMarker}`) + + await expect + .poll(() => getTerminalContent(client.page), { timeout: 20_000 }) + .toContain(`LIVE:${missingMarker}`) + await expect(tab).toHaveAttribute('data-active', 'true') + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + const recoveredHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(recoveredHostTerminal.terminal.ptyId).toBe(originalHostTerminal.terminal.ptyId) + const hostTerminals = await callRuntime(orcaPage, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + expect( + hostTerminals.terminals + .filter((candidate) => candidate.tabId === created.tab.parentTabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + ).toEqual([{ handle: terminal, ptyId: originalHostTerminal.terminal.ptyId }]) + + const liveMarker = `PROBE_GAP_LIVE_${Date.now()}` + await textarea.focus() + await client.page.keyboard.type(liveMarker) + await client.page.keyboard.press('Enter') + await expect + .poll(() => getTerminalContent(client.page), { timeout: 10_000 }) + .toContain(`LIVE:${liveMarker}`) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${liveMarker}\n`) + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + } finally { + await client.page + .evaluate(() => { + ;( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { release: () => void } + } + ).__remoteTerminalMultiplexAckGate?.release() + }) + .catch(() => undefined) + if (terminal) { + await callRuntime(orcaPage, 'terminal.closeTab', { terminal }).catch(() => undefined) + } + await client.dispose() + } +}) From 8f7692aa12ec556dfd7b7244a66c58868b692f10 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:27:16 -0700 Subject: [PATCH 029/123] Fix packaged skills CLI runtime ownership (#11627) * fix(cli): make packaged skills runtime self-contained * fix(cli): address packaged skills review feedback * ci(cli): smoke packaged skills on Windows --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .github/workflows/pr.yml | 54 +++- config/electron-builder.config.cjs | 18 +- .../scripts/electron-builder-config.test.mjs | 33 ++- .../scripts/pr-workflow-parallelism.test.mjs | 3 +- .../skills-cli-package-workflow.test.mjs | 31 +++ config/scripts/smoke-packaged-cli.mjs | 40 ++- config/scripts/verify-skills-cli-runtime.cjs | 229 +++++++++++++++++ .../verify-skills-cli-runtime.test.mjs | 230 ++++++++++++++++++ config/tsconfig.cli.json | 2 - electron.vite.config.ts | 12 +- package.json | 6 +- src/cli/handlers/account.test.ts | 4 +- src/cli/handlers/account.ts | 7 +- src/cli/handlers/skills.ts | 11 +- src/cli/main-module-bundle-parity.test.ts | 8 +- src/cli/skills.test.ts | 6 +- src/main/codex-cli/command.ts | 227 +---------------- .../ipc/local-agent-install-dir-detection.ts | 19 +- src/main/ipc/preflight.test.ts | 2 +- src/main/ipc/tui-agent-detection-commands.ts | 78 +----- src/main/win32-utils.test.ts | 24 +- src/main/win32-utils.ts | 69 +----- .../local-agent-install-dir-detection.ts | 17 ++ src/shared/node-cli-command-resolution.ts | 218 +++++++++++++++++ src/shared/tui-agent-detection-commands.ts | 77 ++++++ src/shared/windows-batch-spawn.ts | 43 ++++ 26 files changed, 1033 insertions(+), 435 deletions(-) create mode 100644 config/scripts/skills-cli-package-workflow.test.mjs create mode 100644 config/scripts/verify-skills-cli-runtime.cjs create mode 100644 config/scripts/verify-skills-cli-runtime.test.mjs create mode 100644 src/shared/local-agent-install-dir-detection.ts create mode 100644 src/shared/node-cli-command-resolution.ts create mode 100644 src/shared/tui-agent-detection-commands.ts create mode 100644 src/shared/windows-batch-spawn.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4888dd6f3b3..2f0e19980a6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -273,6 +273,55 @@ jobs: - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + package_windows: + name: package (windows) + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-windows- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package inputs + run: pnpm run build:release + + - name: Prepare Electron native runtime + run: node config/scripts/ensure-native-runtime.mjs --runtime=electron + + - name: Package unpacked app + env: + ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir + + - name: Smoke packaged CLI + run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked + # Why: regression specs under tests/e2e/** used to merge green without ever # running — e2e.yml only fired on schedule/release (#10518). Path-filter so # ordinary PRs stay light; any E2E suite change still gets a full shard run. @@ -331,6 +380,7 @@ jobs: - shell_contracts - test - package + - package_windows runs-on: ubuntu-latest steps: @@ -350,6 +400,7 @@ jobs: SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} TEST: ${{ needs.test.result }} PACKAGE: ${{ needs.package.result }} + PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | for result in \ "$STATIC_ANALYSIS" \ @@ -357,7 +408,8 @@ jobs: "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ "$TEST" \ - "$PACKAGE"; do + "$PACKAGE" \ + "$PACKAGE_WINDOWS"; do if [ "$result" != "success" ]; then exit 1 fi diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a2d385bd698..e7a80704c14 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,7 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -143,16 +144,12 @@ module.exports = { 'out/main/claude/**', 'out/main/claude-accounts/keychain.js', 'out/main/codex/**', - 'out/main/codex-cli/command.js', 'out/main/copilot/**', 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', - 'out/main/ipc/local-agent-install-dir-detection.js', - 'out/main/ipc/tui-agent-detection-commands.js', - 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', @@ -183,7 +180,7 @@ module.exports = { ) : join(context.appOutDir, 'resources') if (!existsSync(resourcesDir)) { - return + throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } if (context.electronPlatformName === 'darwin') { const architectureByEnum = { 1: 'x64', 3: 'arm64' } @@ -213,7 +210,16 @@ module.exports = { // arm64=3, universal=4 (universal contains the host slice, so run it). const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] - if (context.arch === hostArchEnum || context.arch === 4) { + const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { + executeCommands: canExecuteTargetArch + }) + if (!canExecuteTargetArch) { + console.log( + `[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})` + ) + } + if (canExecuteTargetArch) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { // Why: a cross-arch slice can't be booted by the host Node, but the diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index f90d6e49b5a..7d761c70af7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -158,11 +158,7 @@ describe('electron-builder config', () => { 'out/package.json', 'out/cli/**', 'out/shared/**', - 'out/main/claude-accounts/keychain.js', - 'out/main/codex-cli/command.js', - 'out/main/ipc/local-agent-install-dir-detection.js', - 'out/main/ipc/tui-agent-detection-commands.js', - 'out/main/win32-utils.js' + 'out/main/claude-accounts/keychain.js' ]) ) }) @@ -486,6 +482,20 @@ describe('electron-builder config', () => { } }) + it('fails when the packaged resources directory is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32' + }) + ).rejects.toThrow(/Missing packaged resources directory/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'marks packaged Unix CLI launchers executable', async () => { @@ -509,6 +519,19 @@ describe('electron-builder config', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n', 'utf8' ) + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile( + join(unpackedCliDir, 'index.js'), + [ + 'const args = process.argv.slice(2)', + "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", + "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", + 'else console.log(JSON.stringify({ executed: false }))' + ].join('\n'), + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6c758a74fe1..dbece23cafd 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => { 'git_compatibility', 'shell_contracts', 'test', - 'package' + 'package', + 'package_windows' ]) }) }) diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs new file mode 100644 index 00000000000..72e6cf25829 --- /dev/null +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + +describe('packaged skills CLI PR gates', () => { + it('builds and executes the Windows packaged CLI', () => { + const job = workflow.jobs.package_windows + const buildStep = job.steps.find((step) => step.name === 'Build package inputs') + const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') + const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI') + + expect(job['runs-on']).toBe('windows-2022') + expect(buildStep.run).toBe('pnpm run build:release') + expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(packageStep.run).toContain('electron-builder') + expect(packageStep.run).toContain('--dir') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + expect(smokeStep.run).toBe( + 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked' + ) + + const aggregateStep = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') + expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"') + }) +}) diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs index 269118d867e..4cecf49ad4b 100644 --- a/config/scripts/smoke-packaged-cli.mjs +++ b/config/scripts/smoke-packaged-cli.mjs @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { promisify } from 'node:util' +import assert from 'node:assert/strict' const execFileAsync = promisify(execFile) @@ -37,10 +38,41 @@ const copiedAppDir = join(tempRoot, basename(appDir)) try { await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true }) const cliPath = getPackagedCliPath(copiedAppDir) - await execFileAsync(cliPath, ['--help'], { - env: { ...process.env, NODE_PATH: '' } - }) - console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`) + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const run = (args) => + execFileAsync(cliPath, args, { + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: 30_000 + }) + + await run(['--help']) + const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout) + assert(list.topics.some((topic) => topic.name === 'orca-cli')) + assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/) + assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/) + const install = JSON.parse( + ( + await run([ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ).stdout + ) + const update = JSON.parse( + (await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout + ) + assert.equal(install.executed, false) + assert.equal(update.executed, false) + console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`) } finally { await rm(tempRoot, { recursive: true, force: true }) } diff --git a/config/scripts/verify-skills-cli-runtime.cjs b/config/scripts/verify-skills-cli-runtime.cjs new file mode 100644 index 00000000000..0a76078d318 --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.cjs @@ -0,0 +1,229 @@ +const { existsSync, readFileSync, realpathSync } = require('node:fs') +const { builtinModules, createRequire, isBuiltin } = require('node:module') +const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path') +const { spawnSync } = require('node:child_process') +const ts = require('typescript-api') + +const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])) +const CLI_COMMAND_TIMEOUT_MS = 30_000 + +function artifactPath(outDir, file) { + return relative(outDir, file).split(sep).join('/') +} + +function runtimeImportSpecifiers(source, file) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS + ) + const specifiers = [] + + function visit(node) { + if (ts.isCallExpression(node) && node.arguments.length > 0) { + const [argument] = node.arguments + const expression = node.expression + const isRequire = ts.isIdentifier(expression) && expression.text === 'require' + const isRequireResolve = + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === 'require' && + expression.name.text === 'resolve' + const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword + + if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) { + specifiers.push(argument.text) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return specifiers +} + +function isOutsideRoot(root, target) { + const pathFromRoot = relative(root, target) + return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`) +} + +function isOptionalPackageImport(artifactRoot, importer, specifier) { + if (specifier.startsWith('.') || isAbsolute(specifier)) { + return false + } + const segments = specifier.split('/') + const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] + let directory = realpathSync(dirname(importer)) + + while (!isOutsideRoot(artifactRoot, directory)) { + const packageJson = join(directory, 'package.json') + if (existsSync(packageJson)) { + try { + const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) + return ( + Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) || + manifest.peerDependenciesMeta?.[packageName]?.optional === true + ) + } catch { + return false + } + } + if (directory === artifactRoot) { + break + } + directory = dirname(directory) + } + return false +} + +function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) { + if (BUILTINS.has(specifier) || isBuiltin(specifier)) { + return null + } + let resolved + try { + resolved = createRequire(importer).resolve(specifier) + } catch (error) { + if (isOptionalPackageImport(artifactRoot, importer, specifier)) { + return null + } + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` + + `${artifactPath(outDir, importer)}: ${detail}` + ) + } + if (isOutsideRoot(artifactRoot, resolved)) { + throw new Error( + `[verify-skills-cli-runtime] import "${specifier}" from ` + + `${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}` + ) + } + return resolved +} + +function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) { + outDir = realpathSync(outDir) + artifactRoot = realpathSync(artifactRoot) + if (isOutsideRoot(artifactRoot, outDir)) { + throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`) + } + const entry = resolve(outDir, 'cli', 'index.js') + if (!existsSync(entry)) { + throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`) + } + const pending = [entry] + const visited = new Set() + + while (pending.length > 0) { + const file = pending.pop() + if (!file || visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const specifier of runtimeImportSpecifiers(source, file)) { + const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier) + if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) { + pending.push(resolved) + } + } + } + + return [...visited].sort() +} + +function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) { + const entry = resolve(outDir, 'cli', 'index.js') + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const result = spawnSync(process.execPath, [entry, ...args], { + cwd: dirname(outDir), + encoding: 'utf8', + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: timeoutMs + }) + if (result.error || result.signal || result.status !== 0) { + const detail = [ + result.error?.message, + result.signal ? `terminated by ${result.signal}` : null, + result.stdout, + result.stderr + ] + .filter(Boolean) + .join('\n') + throw new Error( + `[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}` + ) + } + return result.stdout +} + +function parseJson(label, output) { + try { + return JSON.parse(output) + } catch { + throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`) + } +} + +function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) { + const absoluteOutDir = resolve(outDir) + const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot)) + if (options.executeCommands === false) { + return { closureFiles: closure.length, commands: 0 } + } + const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json'])) + const topicNames = new Set(list.topics?.map((topic) => topic.name)) + for (const topic of ['orca-cli', 'computer-use']) { + if (!topicNames.has(topic)) { + throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`) + } + const guide = runCli(absoluteOutDir, ['skills', 'get', topic]) + if (!guide.includes(`name: ${topic}`)) { + throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`) + } + } + + const install = parseJson( + 'skills install --dry-run', + runCli(absoluteOutDir, [ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ) + const update = parseJson( + 'skills update --dry-run', + runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json']) + ) + if (install.executed !== false || update.executed !== false) { + throw new Error('[verify-skills-cli-runtime] a dry-run reported execution') + } + + return { closureFiles: closure.length, commands: 5 } +} + +if (require.main === module) { + try { + const result = verifySkillsCliRuntime(process.argv[2] ?? 'out') + console.log( + `[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` + + `${result.commands} commands passed` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} + +module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime } diff --git a/config/scripts/verify-skills-cli-runtime.test.mjs b/config/scripts/verify-skills-cli-runtime.test.mjs new file mode 100644 index 00000000000..f60adfd38ac --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.test.mjs @@ -0,0 +1,230 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + collectRuntimeClosure, + runCli, + verifySkillsCliRuntime +} = require('./verify-skills-cli-runtime.cjs') + +async function writeSkillsCliFixture(outDir, handlerSource) { + const cliDir = join(outDir, 'cli') + const handlerDir = join(cliDir, 'handlers') + await mkdir(handlerDir, { recursive: true }) + await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8') + await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8') +} + +describe('skills CLI runtime closure', () => { + it('runs after Electron composes the final output', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8') + ) + for (const scriptName of ['build:desktop', 'build:release']) { + const script = packageJson.scripts[scriptName] + expect(script.indexOf('build:electron-vite')).toBeLessThan( + script.indexOf('verify:built-skills-cli') + ) + } + }) + + it('reports the missing final-artifact import and its owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n") + + expect(() => collectRuntimeClosure(root)).toThrow( + /missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('walks static and dynamic relative imports', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const sharedDir = join(root, 'shared') + await mkdir(sharedDir, { recursive: true }) + await writeSkillsCliFixture( + root, + "require('../../shared/first.js'); import('../../shared/second.js')\n" + ) + await writeFile(join(sharedDir, 'first.js'), '', 'utf8') + await writeFile(join(sharedDir, 'second.js'), '', 'utf8') + + expect( + collectRuntimeClosure(root) + .map((file) => relative(realpathSync(root), file)) + .sort() + ).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js']) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('ignores import-shaped text in comments and strings', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture( + root, + [ + "// require('../../missing-comment.js')", + 'const message = "import(\'../../missing-string.js\')"', + "const template = `require.resolve('../../missing-template.js')`" + ].join('\n') + ) + + expect(collectRuntimeClosure(root)).toHaveLength(2) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('can inspect a cross-arch artifact without executing it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, '') + + expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({ + closureFiles: 2, + commands: 0 + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('bounds command execution time', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n') + + expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects bare imports resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const externalPackageDir = join(root, 'node_modules', 'external-package') + await mkdir(externalPackageDir, { recursive: true }) + await writeSkillsCliFixture(outDir, "require('external-package')\n") + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /external-package.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects package dependencies resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /ancestor-dependency.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows absent dependencies declared optional by their package', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + peerDependencies: { 'optional-native': '*' }, + peerDependenciesMeta: { 'optional-native': { optional: true } } + }), + 'utf8' + ) + await writeFile( + join(packageDir, 'index.js'), + "try { require('optional-native') } catch {}\n", + 'utf8' + ) + + expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects optional dependencies resolved only outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'optional-native') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + optionalDependencies: { 'optional-native': '*' } + }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /optional-native.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 6bbfa8814f3..f4247281300 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,8 +12,6 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", - "../src/main/ipc/local-agent-install-dir-detection.ts", - "../src/main/ipc/tui-agent-detection-commands.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 562d6e9db91..4a4cd6c8eba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -260,16 +260,8 @@ export const electronViteConfig: UserConfig = { 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' ), - 'ipc/local-agent-install-dir-detection': resolve( - 'src/main/ipc/local-agent-install-dir-detection.ts' - ), - 'ipc/tui-agent-detection-commands': resolve( - 'src/main/ipc/tui-agent-detection-commands.ts' - ), - // Why: same rule — `orca account add` / `account list` import these. - 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'), - 'codex-cli/command': resolve('src/main/codex-cli/command.ts'), - 'win32-utils': resolve('src/main/win32-utils.ts') + // Why: account import mutates the user's macOS Keychain from the CLI. + 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. diff --git a/package.json b/package.json index e7effafc8fc..4c1e8474517 100644 --- a/package.json +++ b/package.json @@ -58,19 +58,21 @@ "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", + "verify:built-skills-cli": "node config/scripts/verify-skills-cli-runtime.cjs out", "verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs", "sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix", "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", + "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 97f7bee9c92..d39a5f33293 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -41,7 +41,7 @@ vi.mock('../../main/claude-accounts/keychain', () => ({ readActiveClaudeKeychainCredentialsStrict: readKeychainMock, writeActiveClaudeKeychainCredentials: writeKeychainMock })) -vi.mock('../../main/codex-cli/command', () => ({ +vi.mock('../../shared/node-cli-command-resolution', () => ({ getVersionManagerBinPaths: getVersionManagerBinPathsMock, resolveCliCommand: resolveCliCommandMock })) @@ -49,7 +49,7 @@ vi.mock('../../main/codex-cli/command', () => ({ import { ACCOUNT_HANDLERS } from './account' import type { HandlerContext } from '../dispatch' import type { RuntimeClient } from '../runtime-client' -import { getCmdExePath } from '../../main/win32-utils' +import { getCmdExePath } from '../../shared/windows-batch-spawn' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' function successfulChild(): EventEmitter { diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0a7a3212d66..0ad19aa240d 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -12,8 +12,11 @@ import { readActiveClaudeKeychainCredentialsStrict, writeActiveClaudeKeychainCredentials } from '../../main/claude-accounts/keychain' -import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command' -import { getSpawnArgsForWindows } from '../../main/win32-utils' +import { + getVersionManagerBinPaths, + resolveCliCommand +} from '../../shared/node-cli-command-resolution' +import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 26e9ea5a734..7c911ea1751 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' import { delimiter, dirname } from 'node:path' import { getRepeatedStringFlag } from '../flags' -import { resolveCliCommand } from '../../main/codex-cli/command' -import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' import { getTuiAgentDetectionProbeCommands, KNOWN_TUI_AGENT_DETECTION_COMMANDS, resolveDetectedTuiAgentIds -} from '../../main/ipc/tui-agent-detection-commands' -import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils' +} from '../../shared/tui-agent-detection-commands' +import { + getSpawnArgsForWindows, + UnsafeWindowsBatchArgumentsError +} from '../../shared/windows-batch-spawn' import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys' import { buildAgentFeatureSkillInstallArgs, diff --git a/src/cli/main-module-bundle-parity.test.ts b/src/cli/main-module-bundle-parity.test.ts index 8d00749f65b..a109fcb9fdb 100644 --- a/src/cli/main-module-bundle-parity.test.ts +++ b/src/cli/main-module-bundle-parity.test.ts @@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set { describe('CLI imports of main-process modules', () => { // Why: electron-vite cleans out/main and emits only its declared entries, so a // `src/main/*` module the CLI imports but the config omits is deleted by the - // build that runs after `build:cli` — the command then dies at require time - // with "Cannot find module". Nothing else catches this before packaging. + // build that runs after `build:cli` — keep source-level feedback ahead of the + // final-artifact runtime verifier. it('has an electron-vite entry for every main module the CLI imports', () => { const entries = findElectronViteMainEntries() const missing = findMainImports().filter(({ module }) => !entries.has(module)) @@ -52,7 +52,7 @@ describe('CLI imports of main-process modules', () => { it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. - expect(findMainImports().length).toBeGreaterThanOrEqual(4) - expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4) + expect(findMainImports().length).toBeGreaterThanOrEqual(2) + expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 1cdee5aad4b..11caa30d704 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { delimiter } from 'node:path' -import type * as CodexCliCommandModule from '../main/codex-cli/command' +import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution' const { detectCommandsMock, @@ -19,12 +19,12 @@ const { // Why: agent detection probes the real machine, so pin it or every install // assertion depends on what the test runner happens to have installed. -vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({ +vi.mock('../shared/local-agent-install-dir-detection', () => ({ detectCommandsInInstallDirs: detectCommandsMock })) // Why: override only the npx lookup so the real Windows .cmd rail still runs. -vi.mock('../main/codex-cli/command', async (importOriginal) => ({ +vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({ ...(await importOriginal()), resolveCliCommand: resolveCliCommandMock })) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 177b7e0ea9c..61e08eb457e 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,226 +1 @@ -import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' -import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' - -type ResolveCommandOptions = { - pathEnv?: string | null - platform?: NodeJS.Platform - homePath?: string -} - -function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { - if (platform === 'win32') { - return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] - } - - return [commandName] -} - -function splitPath(pathEnv: string | null | undefined): string[] { - if (!pathEnv) { - return [] - } - - return pathEnv - .split(delimiter) - .map((entry) => entry.trim()) - .filter(Boolean) -} - -function parseVersionSegment(raw: string): number[] { - return raw - .replace(/^v/i, '') - .split('.') - .map((segment) => Number.parseInt(segment, 10)) - .map((segment) => (Number.isFinite(segment) ? segment : 0)) -} - -function compareVersionDesc(left: string, right: string): number { - const leftParts = parseVersionSegment(left) - const rightParts = parseVersionSegment(right) - const length = Math.max(leftParts.length, rightParts.length) - - for (let index = 0; index < length; index += 1) { - const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) - if (delta !== 0) { - return delta - } - } - - return right.localeCompare(left) -} - -function findFirstExecutable( - platform: NodeJS.Platform, - directories: string[], - executableNames: string[] -): string | null { - for (const directory of directories) { - for (const executableName of executableNames) { - const candidate = join(directory, executableName) - if (isRunnableCommand(platform, candidate)) { - return candidate - } - } - } - - return null -} - -function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { - try { - const stats = statSync(candidate) - if (!stats.isFile()) { - return false - } - if (platform === 'win32') { - return true - } - // Why: GUI fallback probing should skip placeholders/directories so spawn - // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. - accessSync(candidate, constants.X_OK) - return true - } catch { - return false - } -} - -function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { - const directories = [ - join(homePath, '.volta', 'bin'), - join(homePath, '.asdf', 'shims'), - join(homePath, '.fnm', 'aliases', 'default', 'bin'), - // Why: mise (formerly rtx) exposes managed tool binaries via a shims - // directory, similar to asdf. Without this, users who installed node - // or CLI tools through mise can't be found by the fallback probe. - join(homePath, '.local', 'share', 'mise', 'shims') - ] - - if (platform === 'win32') { - // Why: Anthropic's native Windows installer places claude.exe here, and - // GUI-launched Orca may not inherit the user's PATH entry for it. - directories.push(join(homePath, '.local', 'bin')) - directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) - directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) - directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) - } else { - directories.push(join(homePath, '.local', 'bin')) - // Why: pnpm uses platform-specific global bin directories that differ from - // npm's ~/.local/bin. macOS follows the ~/Library convention while Linux - // uses the XDG-compatible ~/.local/share path. Without these, users who - // installed via `pnpm add -g` can't be found by the fallback probe. - if (platform === 'darwin') { - directories.push(join(homePath, 'Library', 'pnpm')) - } else { - directories.push(join(homePath, '.local', 'share', 'pnpm')) - } - directories.push(join(homePath, '.yarn', 'bin')) - } - - // Why: bun uses ~/.bun/bin on all platforms for globally installed packages. - directories.push(join(homePath, '.bun', 'bin')) - - return directories -} - -function getNvmVersionDirectories(homePath: string): string[] { - const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') - if (!existsSync(nvmVersionsDir)) { - return [] - } - - return readdirSync(nvmVersionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(compareVersionDesc) - .map((entry) => join(nvmVersionsDir, entry, 'bin')) -} - -function getVersionManagerDirectories( - platform: NodeJS.Platform, - homePath: string, - executableNames: string[] -): string[] { - const directories = getBaseVersionManagerDirectories(platform, homePath) - - // Why: GUI-launched Electron apps do not inherit shell init from nvm, so - // command resolution probes the newest installed Node versions explicitly. - const firstNvmMatch = findFirstExecutable( - platform, - getNvmVersionDirectories(homePath), - executableNames - ) - if (firstNvmMatch) { - directories.unshift(dirname(firstNvmMatch)) - } - - return directories -} - -export function resolveCliCommand( - commandName: string, - options: ResolveCommandOptions = {} -): string { - const platform = options.platform ?? process.platform - const executableNames = getExecutableNames(platform, commandName) - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) - if (pathCandidate) { - return pathCandidate - } - - const homePath = options.homePath ?? homedir() - const versionManagerCandidate = findFirstExecutable( - platform, - getVersionManagerDirectories(platform, homePath, executableNames), - executableNames - ) - return versionManagerCandidate ?? commandName -} - -export function resolveCliCommands( - commandNames: readonly string[], - options: ResolveCommandOptions = {} -): Map { - const platform = options.platform ?? process.platform - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathDirectories = splitPath(pathEnv) - const homePath = options.homePath ?? homedir() - // Why: agent detection probes many CLIs at once; compute expensive install - // directories, especially nvm versions, once per detection pass. - const installDirectories = [ - ...getNvmVersionDirectories(homePath), - ...getBaseVersionManagerDirectories(platform, homePath) - ] - const resolved = new Map() - - for (const commandName of new Set(commandNames)) { - const executableNames = getExecutableNames(platform, commandName) - const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) - const installCandidate = - pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) - resolved.set(commandName, installCandidate ?? commandName) - } - - return resolved -} - -export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('codex', options) -} - -export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('claude', options) -} - -// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node -// version manager directories. CLI tools like codex/claude are Node scripts -// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute, -// not just to be *found*. This function returns the version manager bin paths -// so the caller can augment process.env.PATH at startup. -export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { - const platform = options.platform ?? process.platform - const homePath = options.homePath ?? homedir() - const nodeNames = getExecutableNames(platform, 'node') - return getVersionManagerDirectories(platform, homePath, nodeNames) -} +export * from '../../shared/node-cli-command-resolution' diff --git a/src/main/ipc/local-agent-install-dir-detection.ts b/src/main/ipc/local-agent-install-dir-detection.ts index 1364b5bc7d7..7493affdb47 100644 --- a/src/main/ipc/local-agent-install-dir-detection.ts +++ b/src/main/ipc/local-agent-install-dir-detection.ts @@ -1,18 +1 @@ -import path from 'node:path' -import { resolveCliCommands } from '../codex-cli/command' - -// Why: local agent detection may run before shell-PATH hydration, but the -// fallback must stay bounded because it runs on the main process. -export function detectCommandsInInstallDirs(commands: readonly string[]): Set { - if (commands.length === 0) { - return new Set() - } - try { - const resolvedCommands = resolveCliCommands(commands) - return new Set( - commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) - ) - } catch { - return new Set() - } -} +export { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index fead0c97c86..c959bda20a1 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -54,7 +54,7 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) -vi.mock('../codex-cli/command', () => ({ +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts index dc5d5909543..88d65c1fa34 100644 --- a/src/main/ipc/tui-agent-detection-commands.ts +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -1,77 +1 @@ -import type { TuiAgent } from '../../shared/types' -import { - getTuiAgentDetectCommands, - TUI_AGENT_CONFIG, - type TuiAgentConfig, - type TuiAgentDetectionRuntime -} from '../../shared/tui-agent-config' - -export type TuiAgentDetectionCommand = { - id: TuiAgent - cmd: string - requiredCommands?: readonly string[] - unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] -} - -export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() - -function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { - return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => - getTuiAgentDetectCommands(config).map((cmd) => - buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) - ) - ) -} - -function buildTuiAgentDetectionCommand( - id: TuiAgent, - cmd: string, - config: TuiAgentConfig -): TuiAgentDetectionCommand { - return { - id, - cmd, - ...(config.detectRequiredCommands?.length - ? { requiredCommands: config.detectRequiredCommands } - : {}), - ...(config.detectUnsupportedRuntimes?.length - ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } - : {}) - } -} - -export function getTuiAgentDetectionProbeCommands( - commands: readonly TuiAgentDetectionCommand[], - runtime: TuiAgentDetectionRuntime -): string[] { - return [ - ...new Set( - commands - .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) - .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) - ) - ] -} - -export function resolveDetectedTuiAgentIds( - commands: readonly TuiAgentDetectionCommand[], - foundCommands: ReadonlySet, - runtime: TuiAgentDetectionRuntime -): TuiAgent[] { - const detected = commands - .filter( - (command) => - !isDetectionUnsupportedInRuntime(command, runtime) && - foundCommands.has(command.cmd) && - (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) - ) - .map(({ id }) => id) - return [...new Set(detected)] -} - -export function isDetectionUnsupportedInRuntime( - command: TuiAgentDetectionCommand, - runtime: TuiAgentDetectionRuntime -): boolean { - return command.unsupportedRuntimes?.includes(runtime) === true -} +export * from '../../shared/tui-agent-detection-commands' diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index abf0b0964be..3f719e5ae34 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -119,17 +119,29 @@ describe('getSpawnArgsForWindows', () => { it('rejects unsafe args for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['hello & goodbye'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const argument of ['hello & goodbye', 'close)', '(open']) { + expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [argument])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } }) }) it('rejects unsafe command paths for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const command of ['C:\\bad&path\\agent.cmd', 'C:\\bad(path\\agent.cmd']) { + expect(() => getSpawnArgsForWindows(command, ['login'])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } + }) + }) + + it('allows punctuation that is not a cmd command operator', () => { + withPlatform('win32', () => { + expect( + getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['package,name;version']).spawnArgs + ).toEqual(['/d', '/c', 'C:\\tools\\agent.cmd', 'package,name;version']) }) }) }) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 4edc47b93a1..21f3a75853d 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -2,6 +2,14 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from ' import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' +export { + getCmdExePath, + getSpawnArgsForWindows, + isWindowsBatchScript, + WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, + UnsafeWindowsBatchArgumentsError +} from '../shared/windows-batch-spawn' + function execFileWithoutBlocking( command: string, args: string[], @@ -38,20 +46,6 @@ export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { return win32.join(root, 'System32', 'reg.exe') } -/** - * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in - * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). - * Falls back to SystemRoot-based path if ComSpec is unset. - */ -export function getCmdExePath(): string { - return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` -} - -/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */ -export function isWindowsBatchScript(commandPath: string): boolean { - return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) -} - export function resolveWindowsCommand( command: string, env: NodeJS.ProcessEnv = process.env @@ -79,19 +73,6 @@ export function resolveWindowsCommand( return command } -export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - -export class UnsafeWindowsBatchArgumentsError extends Error { - constructor() { - super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) - this.name = 'UnsafeWindowsBatchArgumentsError' - } -} - -function hasUnsafeWindowsBatchSyntax(value: string): boolean { - return /[&|<>^"%!\r\n]/.test(value) -} - /** Check whether an error is a Windows permission error (EACCES or EPERM). */ export function isPermissionError(error: unknown): boolean { return ( @@ -235,37 +216,3 @@ export async function grantDirAclAsync(dirPath: string): Promise { } ) } - -/** - * Resolve spawn parameters for a command that may be a Windows batch script. - * - * Why: Node's spawn() cannot execute .cmd/.bat files directly without - * shell:true, but shell:true with an args array triggers DEP0190 because - * args are concatenated, not escaped. Routing through cmd.exe /c explicitly - * avoids the deprecation warning while passing args correctly. - * - * Why /d: disables per-machine/user AutoRun registry commands so a background - * spawn cannot inherit surprising side effects from the user's shell config. - * - * SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command - * line. Args with cmd metacharacters are rejected instead of escaped because - * the agent prompt may contain arbitrary staged diff text. - */ -export function getSpawnArgsForWindows( - command: string, - args: string[] -): { spawnCmd: string; spawnArgs: string[] } { - if (isWindowsBatchScript(command)) { - for (const value of [command, ...args]) { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new UnsafeWindowsBatchArgumentsError() - } - } - - // Why: when Node passes a pre-quoted command line as one argv entry, - // cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd - // shims. Separate argv entries let Node quote spaces without breaking cmd. - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } - } - return { spawnCmd: command, spawnArgs: args } -} diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts new file mode 100644 index 00000000000..9b29a142f9e --- /dev/null +++ b/src/shared/local-agent-install-dir-detection.ts @@ -0,0 +1,17 @@ +import path from 'node:path' +import { resolveCliCommands } from './node-cli-command-resolution' + +// Why: detection may precede shell-PATH hydration, but the fallback stays bounded. +export function detectCommandsInInstallDirs(commands: readonly string[]): Set { + if (commands.length === 0) { + return new Set() + } + try { + const resolvedCommands = resolveCliCommands(commands) + return new Set( + commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) + ) + } catch { + return new Set() + } +} diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts new file mode 100644 index 00000000000..44e85c4dd08 --- /dev/null +++ b/src/shared/node-cli-command-resolution.ts @@ -0,0 +1,218 @@ +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' + +type ResolveCommandOptions = { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string +} + +function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { + if (platform === 'win32') { + return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] + } + + return [commandName] +} + +function splitPath(pathEnv: string | null | undefined): string[] { + if (!pathEnv) { + return [] + } + + return pathEnv + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + +function findFirstExecutable( + platform: NodeJS.Platform, + directories: string[], + executableNames: string[] +): string | null { + for (const directory of directories) { + for (const executableName of executableNames) { + const candidate = join(directory, executableName) + if (isRunnableCommand(platform, candidate)) { + return candidate + } + } + } + + return null +} + +function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { + try { + const stats = statSync(candidate) + if (!stats.isFile()) { + return false + } + if (platform === 'win32') { + return true + } + // Why: GUI fallback probing should skip placeholders/directories so spawn + // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} + +function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { + const directories = [ + join(homePath, '.volta', 'bin'), + join(homePath, '.asdf', 'shims'), + join(homePath, '.fnm', 'aliases', 'default', 'bin'), + // Why: mise (formerly rtx) exposes managed tool binaries via a shims + // directory, similar to asdf. + join(homePath, '.local', 'share', 'mise', 'shims') + ] + + if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) + directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) + directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) + directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) + } else { + directories.push(join(homePath, '.local', 'bin')) + // Why: pnpm uses platform-specific global bin directories that differ from + // npm's ~/.local/bin. + if (platform === 'darwin') { + directories.push(join(homePath, 'Library', 'pnpm')) + } else { + directories.push(join(homePath, '.local', 'share', 'pnpm')) + } + directories.push(join(homePath, '.yarn', 'bin')) + } + + directories.push(join(homePath, '.bun', 'bin')) + return directories +} + +function getNvmVersionDirectories(homePath: string): string[] { + const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') + if (!existsSync(nvmVersionsDir)) { + return [] + } + + return readdirSync(nvmVersionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersionDesc) + .map((entry) => join(nvmVersionsDir, entry, 'bin')) +} + +function getVersionManagerDirectories( + platform: NodeJS.Platform, + homePath: string, + executableNames: string[] +): string[] { + const directories = getBaseVersionManagerDirectories(platform, homePath) + const firstNvmMatch = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + if (firstNvmMatch) { + directories.unshift(dirname(firstNvmMatch)) + } + return directories +} + +export function resolveCliCommand( + commandName: string, + options: ResolveCommandOptions = {} +): string { + const platform = options.platform ?? process.platform + const executableNames = getExecutableNames(platform, commandName) + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) + if (pathCandidate) { + return pathCandidate + } + + const homePath = options.homePath ?? homedir() + const nvmCandidate = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + const versionManagerCandidate = + nvmCandidate ?? + findFirstExecutable( + platform, + getBaseVersionManagerDirectories(platform, homePath), + executableNames + ) + return versionManagerCandidate ?? commandName +} + +export function resolveCliCommands( + commandNames: readonly string[], + options: ResolveCommandOptions = {} +): Map { + const platform = options.platform ?? process.platform + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathDirectories = splitPath(pathEnv) + const homePath = options.homePath ?? homedir() + const installDirectories = [ + ...getNvmVersionDirectories(homePath), + ...getBaseVersionManagerDirectories(platform, homePath) + ] + const resolved = new Map() + + for (const commandName of new Set(commandNames)) { + const executableNames = getExecutableNames(platform, commandName) + const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) + const installCandidate = + pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) + resolved.set(commandName, installCandidate ?? commandName) + } + + return resolved +} + +export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('codex', options) +} + +export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('claude', options) +} + +// Why: Node-script CLIs need their version-manager sibling `node` on PATH. +export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { + const platform = options.platform ?? process.platform + const homePath = options.homePath ?? homedir() + const nodeNames = getExecutableNames(platform, 'node') + return getVersionManagerDirectories(platform, homePath, nodeNames) +} diff --git a/src/shared/tui-agent-detection-commands.ts b/src/shared/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..b0ca1b443b8 --- /dev/null +++ b/src/shared/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from './types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from './tui-agent-config' + +export type TuiAgentDetectionCommand = { + id: TuiAgent + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] +} + +export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() + +function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { + return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => + getTuiAgentDetectCommands(config).map((cmd) => + buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) + ) + ) +} + +function buildTuiAgentDetectionCommand( + id: TuiAgent, + cmd: string, + config: TuiAgentConfig +): TuiAgentDetectionCommand { + return { + id, + cmd, + ...(config.detectRequiredCommands?.length + ? { requiredCommands: config.detectRequiredCommands } + : {}), + ...(config.detectUnsupportedRuntimes?.length + ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } + : {}) + } +} + +export function getTuiAgentDetectionProbeCommands( + commands: readonly TuiAgentDetectionCommand[], + runtime: TuiAgentDetectionRuntime +): string[] { + return [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] +} + +export function resolveDetectedTuiAgentIds( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime +): TuiAgent[] { + const detected = commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + return [...new Set(detected)] +} + +export function isDetectionUnsupportedInRuntime( + command: TuiAgentDetectionCommand, + runtime: TuiAgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts new file mode 100644 index 00000000000..cd20dff5c79 --- /dev/null +++ b/src/shared/windows-batch-spawn.ts @@ -0,0 +1,43 @@ +import { win32 } from 'node:path' + +/** Full path to cmd.exe for GUI and service-launched processes. */ +export function getCmdExePath(): string { + return ( + process.env.ComSpec || + win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + ) +} + +export function isWindowsBatchScript(commandPath: string): boolean { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) +} + +export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + +export class UnsafeWindowsBatchArgumentsError extends Error { + constructor() { + super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) + this.name = 'UnsafeWindowsBatchArgumentsError' + } +} + +function hasUnsafeWindowsBatchSyntax(value: string): boolean { + return /[&|<>^"%!()\r\n]/.test(value) +} + +export function getSpawnArgsForWindows( + command: string, + args: string[] +): { spawnCmd: string; spawnArgs: string[] } { + if (isWindowsBatchScript(command)) { + for (const value of [command, ...args]) { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new UnsafeWindowsBatchArgumentsError() + } + } + + // Why: separate argv entries let Node quote spaces without breaking cmd. + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } + } + return { spawnCmd: command, spawnArgs: args } +} From f138986541db9db37fa6b1e6e605f47e7e23dc77 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:36:02 -0700 Subject: [PATCH 030/123] fix(worktree): use local base when remote tracking ref is unavailable (#11065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree): use local base after offline refresh failure * docs: add git worktree bug queue report * fix(skills): require updater registration (#11051) * fix(skills): require updater registration * fix(skills): reject incomplete updater registrations --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * test(worktree): satisfy changed-code quality gate * docs: record #11065 CI repair status * docs: record CI rate-limit evidence * chore: keep bug category report out of product diff * release: v1.4.160-rc.3 * fix(quality): enforce performance-safe lint baseline (#11074) * fix(quality): clear safe existing lint findings * fix(quality): keep lint cleanup allocation-free * fix(quality): enforce performance-safe baseline * test(terminal): drain deferred confirmation cleanup * fix(preflight): refresh Windows PATH on forced CLI checks (#10091) Refresh the persisted Windows PATH during preflight without blocking Electron's main thread. Bound and deduplicate registry reads, preserve the last good cache on failure, skip host refresh for WSL, and add Windows regression coverage. * feat(new-workspace): type-ahead Project and Run-on pickers (#11062) * feat(new-workspace): make the project picker a type-ahead field The Create-worktree Project slot read as bulky and unpolished: a label row, an add-project icon, a 36px outline trigger and a chevron, all spent before choosing anything — then a popover carrying its own *second* search box, two-line rows, and a footer that scrolled out of reach. The field is now the search. Typing filters in place, so the nested search box is gone. Exactly one row is armed at any time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor rather than two competing highlights. Armed is tracked by row key, not index, so a list arriving late over SSH cannot slide a different project under a keypress the user already aimed. Rows are single-line at 28px with an on-row Enter cap that takes space only while armed, and "Add a new project" is pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or no projects at all. Long names and deep paths degrade deliberately: the name keeps up to half the row and the path elides from its middle, so two monorepo siblings stay distinguishable as …/services/checkout-api vs -web where a flat truncate rendered both identically. Recency is derived from when each project last had a workspace created, which is the action this picker is about to repeat — no new store field. The shell keeps data-project-combobox-root + role=combobox and stays focusable, so the composer's initial-focus and project-required handlers still land on it. * fix(new-workspace): align, scroll and loosen the project picker Five fixes to the type-ahead picker, three reported and two found while checking for related breakage. Alignment: the name and its smaller detail line were centred as boxes, so the 12px path sat visibly high against the 14px name. Both now share a baseline, in the committed field and in every row. The dot mark and the Enter cap are chips rather than text, so they stay centred on the row. Scrolling: the mouse wheel did nothing over the list. The composer is a Radix Dialog, and react-remove-scroll cancels wheel events for portaled content outside the dialog's DOM tree — the scrollbar dragged fine but the wheel was dead. The old cmdk list carried a shim for exactly this; the plain scroll pane that replaced it did not, so it has its own now. Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px -> 12px, with a taller Add row and more air above section headings. Escape stranded a query: with the list closed but text still typed, Escape was ignored (it was gated on the list being open), leaving the field showing text that matched nothing and hid the committed project. Escape now always restores the committed display, and only bubbles when there is nothing to undo. Listbox ownership: options sat inside unroled section and scroll wrappers, which breaks the listbox -> option relationship assistive tech relies on. Sections are groups carrying the heading as their label, and the scroll pane is presentational. Both new behaviours are covered by tests verified to fail without the fix. * chore(tools): keep the project-picker design lab The exploration harness behind the picker rewrite: 16 interactive design variants rendered against the app's real tokens and shadcn primitives, so a prototype is a drop-in ProjectCombobox rather than a mockup. Worth keeping because the frames encode bugs that only reproduce in context. DialogFrame renders the picker inside a real Radix Dialog, which is the only way the react-remove-scroll wheel bug shows up; the fixtures carry duplicate display names and deep sibling paths that a naive truncate renders identically. Run with: npx vite --config tools/wt-picker-lab/vite.config.ts * fix(new-workspace): stop the project list flashing open, shrink its empty state Opening the picker read as a double flash. The shared popover surface is translucent and fades 0 -> 1, which is right over the app canvas but wrong here: this popover lands directly on the composer dialog, so for the length of the fade the Name field underneath showed straight through the list and you saw two layers at once. The list now uses an opaque surface and zooms without fading, so it is solid from the first frame. Every other popover keeps the blur and fade. The "No projects match your search." state was a 60px centred block sitting next to 32px rows, which read as a different kind of surface and made an empty result feel like an error. It is now sized and aligned like a row. The lab's dialog frame focused whatever Radix picked first, which popped the Add-project tooltip on open and masked the real problem; it now focuses the name field the way the real composer does. * fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus Four fixes, three reported and one found while sweeping for others. Square mark: the option dot had a `rounded-full` override, so a project read as a circle here and a square everywhere else (jump palette, sidebar). Drop the override and use RepoBadgeMark's own shape. Centred empty state: "No projects match your search." was left-aligned after being shrunk to row height; centre it. Tab-focus blinked the list shut: the field lives in the popover's anchor, not inside its content, so Radix's dismissable layer saw focus land "outside" and closed the list the instant you tabbed in. Focus and pointer events within this control no longer dismiss it; genuine outside events still do. Junk text could strand the field: typing a query that matched nothing and then clicking away left the text sitting there with the list closed, showing no project and no error. A query only means something while the list is open, so closing without committing now clears it. On pressing Create with no project: no change needed. The create gate has not depended on project selection since #4991, and both submit paths already call showProjectRequiredError(), which sets the inline message and turns the field red via aria-invalid. Verified end to end: the button is pressable, the press paints the field destructive, and the message appears beneath it. * feat(new-workspace): rebuild the Run-on picker to match the project picker "Run on" was the last composer field still built the old way: an outline trigger wrapping a cmdk list, two-line rows, and no way to search. It now matches the project picker, so the two fields in the same form read as one control. The field is the search — type to filter hosts, paths and recipes with no nested search box. Exactly one row is armed at a time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with the label and its path on a shared baseline, the path eliding from its middle so two deep sibling paths stay distinguishable. The popover surface is opaque and unfaded because it lands on the composer dialog, where a translucent fade shows the form underneath. Two behaviours the project picker doesn't have are preserved. Disconnected hosts keep their inline Connect action, tracked per host so one stalled connect never blocks the others, and the list stays open so the connecting state is visible. Two rows open nested lists rather than committing: VM recipes, and "Add host" pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or with no hosts at all. Enter and ArrowRight open a submenu; Escape backs out one layer at a time. Extracted from NewWorkspaceComposerCard (-563 lines) into files that each stay under the line limit without a suppression. Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`, aria-disabled, cmdk-separator) that no longer exist. Rewritten against behaviour and the listbox roles instead. All 22 composer tests pass, plus a live sweep of 11 interactions in a real dialog. * fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows Three follow-ups on the two composer pickers. The ↵ cap on the hovered row is gone from both. On a run-target row it sat next to the Connect action and read as a second, competing affordance; the highlight already says what Enter will take. Submenu rows never highlighted under the pointer. They passed a hardcoded `armed={false}`, so the recipe list and the Add-host choices were the only rows in either picker with no hover state. They now track their own hover. "Add a new project" used a chunky FolderPlus where "Add host" uses a plain Plus. Both rows were already the same height and type, so matching the glyph is the whole difference. * fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows Three follow-ups. "Add a new project" goes back to FolderPlus — matching "Add host"'s plain Plus made the two consistent but lost the glyph that says which kind of thing is being added. A disconnected host row no longer repeats its status. The Connect button already says the host isn't connected, so "Connect this host to set up projects" beside it was saying it twice. Rows without a Connect action keep their detail, since there it explains why the host can't run. The Add-host choices go back to two-line cards. Their descriptions explain what you're picking ("Use an existing machine over SSH" vs "Pair another Orca runtime"), unlike a host row's detail, which just labels a host you already recognise. RunTargetRow grows a `stacked` variant for that rather than making the single-line row do both jobs. * fix(new-workspace): give Run on the same vertical rhythm as the other fields Run on is nested inside the Project block so the two share its error and empty states, which also put it on that block's 4px internal spacing. It reads as its own field, so it sat noticeably tighter than the 16px gap every other field in the composer gets. Pad it to match. * refactor(new-workspace): share the type-ahead machinery between both pickers Project and Run on were built one after the other, so each grew its own copy of the same mechanics: query and open state, arming by row key, the arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll wheel shim, and the closes-drops-the-query rule. Two copies of subtle behaviour is two places for it to drift. useTypeAheadCombobox now owns all of it. Callers pass a function that turns a query into row keys and get back the query, the armed key, and the movement helpers. Run on layers its submenu state on top by wrapping `close`, which is the only part that isn't shared. The two long class strings both files repeated verbatim — the field shell and the opaque unfaded popover surface — are named constants now, so the reason they differ from the stock popover recipe is written down once instead of implied by a duplicated literal. No behaviour change: 16,456 renderer tests pass, plus the 22-check live interaction sweep across both pickers in a real dialog. * fix(new-workspace): drop aria-expanded from option rows, remove the design lab `aria-expanded` isn't a supported prop on `role="option"`, so the submenu rows were claiming a state screen readers can't interpret there. `aria-haspopup` alone already says the row opens a menu. Removes tools/wt-picker-lab. It was the harness for exploring this redesign — 12 interactive variants — and it did its job, but the 11 that lost are dead code, and its prototypes were the only thing failing the react-doctor gate (5 errors, all in throwaway variants; the shipped pickers had none). * fix(workspaces): support emoji-only names * feat(workspaces): derive readable emoji identifiers * feat(workspaces): add emoji shortcode picker * fix(terminal): gate the developer menu behind Option and unblock manual parking (#11091) The Developer submenu shipped visible on every worktree right-click, and its Park terminal action always refused with "These terminals cannot be parked safely." - reveal the Developer submenu only when Option/Alt is held at right-click, captured at open time so it can't shift rows mid-menu - stop a settled pendingActivationSpawn tag from refusing a manual park: first activation stamps it on every tab and only a fresh updateTabPtyId consumes it, so a reattached tab kept it forever - resolve the single leaf of a rootless layout for parked watcher coverage, so a workspace whose panes never mounted is no longer permanently uncoverable - restore the !isVisible park guard dropped in #11016, which let the workspace being viewed unmount its own terminals - split manual-park eligibility out of the automatic cold-park policy module * fix(workspaces): hide false repo error for remote servers (#11050) * i18n: translate orchestration page and workflow messages (#11097) Add translations for OrchestrationPage coordinator and child PR names, plus agent workflow status messages (initial states and progress beats) across all supported languages (English, Spanish, Japanese, Korean, Simplified Chinese). * fix(skills): stop reporting a failed update when the CLI succeeded (#11105) The Update skills dialog showed "The update didn't finish" / "Some skills could not be updated" with an armed Retry, directly above the runner's own log line saying "All global skills are up to date" — on a clean exit 0. `skills update` compares its lock's recorded hash against the source and never reads disk (dist/cli.mjs: `latestHash !== entry.skillFolderHash`). Once the lock has advanced past the installed bytes it prints up-to-date, exits 0 and writes nothing. The copy left behind is a recognised older revision, so the post-run re-scan sees `outdated`, skillUpdateFailedNames counted that as a failed run, and skill-update-run settled to state 'error'. Retry re-ran the same command, which no-op'd again — a closed loop. Reclassify `outdated` as "the command did not converge this", not "the run failed". The freshness badge still marks the copy not-current, so nothing is hidden; the run just stops being blamed for it. A botched write is still caught: a half-written bundle hashes to `unrecognized`, a wholly-degraded or removed copy leaves no convergent placement, and process-level failure still surfaces via the spawn error. Reachable by anyone who updated during the stub conversion window — every bundled skill has a stub -> full -> stub oscillation in its last three registry revisions. * perf(terminal): eliminate adverse control and frame-gate cases (#11045) * perf(terminal): eliminate dense control and frame gate regressions * test(terminal): keep gate labels in valid expect shape * test(terminal): expose the surviving sub-threshold control-density case The only adverse strip fixture sat at 50% control density, which is exactly where the fallback fires and wins. A shape at 31 controls per 64-unit block evades the trigger and still loses to the per-character legacy (0.67x), so the benchmark structurally could not show it. Add that fixture, pin both density literals in the staleness guard so a retune fails loudly instead of silently measuring a boundary that moved, and export the probe constant the equivalence test was hardcoding. * fix(opencode): harden lifecycle status delivery (#11017) * fix(agent-status): show Codex v2 subagents (#11059) * fix(agent-status): track Codex rollout subagents * fix(agent-status): resolve cross-day Codex child rollouts and unblock CI gate Codex files each rollout under its own local start date, so a session that runs past midnight spawns children into a sibling day directory. Scanning only the parent's directory left 13% of real subagent spawns (48/371 across local rollouts) permanently unresolved, which pinned a phantom "working" row and re-ran readdirSync every poll tick forever. Resolve the child's own day directory from occurred_at_ms, and time-box a child whose rollout stays unreadable so a deleted or never-written file can't leak a working row. Also make the hook HTTP handler return void: the changed-code quality gate keys findings by span overlap, so this PR's added line inside the pre-existing async createServer callback resurfaced no-misused-promises as a new finding. Tests cover cross-day resolution, grace-period retirement, and that the poll re-arms across successive roster changes (the prior tests passed even when the poll died after its first change). * fix(agent-status): keep the Codex subagent poll alive across nested hooks A nested non-codex CLI inherits its parent's ORCA_PANE_KEY, so its hook POST reached scheduleCodexSubagentPoll and tore the timer down before the source guard, silently ending polling while a rollout child was still live. * fix(skills): stop offering an update the CLI provably cannot perform (#11110) * Revert "fix(skills): stop reporting a failed update when the CLI succeeded (#11105)" This reverts commit a8660839ee734714d4236fd00d960cfde9d97353. * fix(skills): stop offering an update the CLI provably cannot perform The Update skills dialog reported "The update didn't finish" / "Some skills could not be updated" with an armed Retry, directly above the runner's own "All global skills are up to date" log line, on a clean exit 0. `skills update` decides what to do by comparing its lock's `skillFolderHash` against the source tree and never reads disk (published CLI, dist/cli.mjs: `latestHash !== entry.skillFolderHash`). So once the lock records a revision the filesystem does not actually have, it reports up-to-date, exits 0 and writes nothing. No retry converges it. Gate eligibility on that instead of blaming the run afterwards: a name stays updatable only while the lock's hash and the revision the DISK hashes to agree. Those that disagree fall through to `needs-attention`, which skill-freshness-display-status.ts already documents as the state for a copy "out of date somewhere the update command cannot reach". The dialog no longer offers them, so it can neither claim failure nor claim success. Deliberately compared against disk, not the bundled manifest: the updater pulls from the source repo, which legitimately runs ahead of what a build ships, and gating on the bundle would withhold real updates. Both sides must also be positively identified — an unplaceable lock hash is not evidence. Also reverts a8660839ee, which forgave `outdated` post-run. That turned the false failure into a false success ("Updated 2 skills" over copies nothing wrote) and let an empty verdict swallow spawnError, so an offline run published green. Reachable by anyone who updated during the stub-conversion window — every bundled skill has a stub -> full -> stub oscillation in its last three registry revisions. * fix(skills): do not gate a skill when a placement is unidentifiable `diskTreeShas` drops digests matching no known revision, so `every` ran over only the resolved half — one stale copy beside one unidentifiable copy gated the name, contradicting the unknown-stays-eligible rule the function documents. Require every observed digest to resolve before treating all placements as mismatched. Caught by CodeRabbit on #11110. * fix(skills): judge convergence only over placements the update command writes A same-name plugin-cache or repo copy could defeat the gate two ways: an unidentifiable repack read as an unresolved placement, and a cache copy parked at the lock's own revision read as an anchor — both re-arming the unwinnable update on a drifted canonical. Filter to SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, matching eligibility and outcome. * perf(lint): consolidate code-quality gates into Oxlint (#11117) Consolidate standalone code-quality scanners into Oxlint, preserve focused native/type-aware enforcement, add custom plugin coverage, and harden deferred PTY test cleanup. * fix(browser): keep the address bar editable in a narrow toolbar (#11098) * fix(browser): keep the address bar editable in a narrow toolbar Every other browser toolbar control is shrink-0, so the address bar was the only flexible item and absorbed the entire squeeze: below roughly 420px of pane width it collapsed to the leading globe icon with a zero-width input. Clicking it only opened the suggestion dropdown, which inherits `--radix-popover-trigger-width` and so rendered at icon width — there was no way to type or edit a URL in that tab. Focusing a squeezed bar now lifts the form out of the toolbar flow and overlays the row edge to edge, giving a full-width editable field that navigates on Enter (and a full-width suggestion list for free). A measured slot stays in flow so the overlay cannot feed back into its own width, and the slot keeps a min width so the globe remains a real hit target instead of being overlapped by neighbouring buttons. Fixes #11090 Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE * fix(browser): use the documented floating shadow for the expanded bar STYLEGUIDE.md defines exactly three elevation levels and forbids a fourth; shadow-md was not one of them. The overlaid address bar is a floating surface, so it takes the documented floating shadow already used by the other floating surfaces in this pane. Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE * test(browser): make the narrow-toolbar regression deterministic The spec passed only from a clean profile. Two preconditions it set once are actively undone by the app: - BrowserPane re-focuses a blank tab's address bar across several animation frames plus the blank-url did-finish-load handler, so a single blur() was reverted and the bar never reached its squeezed resting state. - Startup paths re-open the right sidebar. At a fixed 700px window that leaves the pane ~70px, so the overlay had nowhere to go and the field measured 0px. Settling these separately let whichever settled first drift back while the next one ran. Re-assert them in one loop until they hold simultaneously, and size the window from the chrome actually measured instead of assuming a fixed 700px. Verified 8/8 green, and still fails at the overlay assertion when the fix is disabled, so the regression coverage stays real. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * Improve orchestration migration safety for live legacy workers (#11107) * fix(orchestration): clarify legacy migration safety * fix(cli): sanitize legacy formatted messages * test(runtime): allow near-cap fuzz under shard load --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(lint): restore nested config discovery in changed-code gate (#11130) * docs: add WeChat group 6 QR with overflow guidance Show group 5 and group 6 QR codes side by side so people can join group 6 if group 5 is full. * fix(sidebar): restore legible selected-workspace fill in dark mode (#11139) #8321 mixed the selected card's wash into the opaque --worktree-sidebar surface, lifting dark mode to 16% (#4b4b4b). Card text lost too much contrast against it. Return both modes to a translucent wash (light 8%, dark 10%) so the brighter selection border added by #8321 carries the selected state instead of the fill. Co-authored-by: Orca * fix(macos): avoid scene deadlock on app reactivation (#11055) * fix(macos): avoid redundant focus on app activation * test(macos): cover passive app activation --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(memory): retune image and orca.yaml ceilings that rejected valid input (#10815) * fix(orchestration): reveal worker terminals reliably (#11142) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * ci(pr): run E2E when a PR touches tests/e2e paths (advisory) (#11131) * ci(pr): run E2E when a PR touches tests/e2e paths Regression specs under tests/e2e never ran on PR CI — only schedule and release called e2e.yml — so a red regression test could merge green. Path-filter and workflow_call the E2E suite when E2E-relevant files change. Use merge-base diffs so base-branch drift does not false-trigger E2E, fail the detector when git diff cannot compute the PR range, and pin least-privilege contents:read on both the detector and reusable E2E workflow. Closes #10518 Co-authored-by: Wooseong Kim Co-authored-by: Orca * ci(pr): make the E2E path gate actually block, and match the real config path Two fixes to the new path-filtered E2E job. The gate did not gate. pr.yml's `verify` job is the required check, and it enumerates its dependencies explicitly — `e2e` was in neither `needs` nor the result list, so a failing shard left `verify` green. That reproduces the exact hole this job exists to close: a red spec merges green, just with a red box further down the page. Add `e2e` to both. Because the job is path-filtered, `skipped` is the normal result on a PR that touches no E2E files and has to keep passing. That allowance is checked after the strict loop rather than inside it, so it can never leak to the six jobs that are always required. The `playwright.` pattern matched nothing. The config is tests/playwright.config.ts — beside tests/e2e/, not inside it — so no tracked file starts with `playwright.` and editing the runner config would silently skip E2E. Anchor it at `tests/playwright.`. Adds a contract test alongside the existing release-e2e one. Verified it fails when either fix is reverted, and simulated the gate across success/skipped/failure/cancelled plus the skip-must-not-mask-a-real-failure case. * test(ci): close two gaps in the E2E gate contract CodeRabbit was right on both counts — verified by reverting each and watching the contract stay green. The path filter was unasserted, so `e2e` could lose its `if:` and run on every PR — the cost the filter exists to avoid — without failing anything. The strict-loop check hardcoded four of the six required jobs, so dropping GIT_COMPATIBILITY or SHELL_CONTRACTS left them unenforced while the contract passed. Derive the list from verify.needs instead, so a newly added required job that misses the loop fails here rather than silently going unchecked. * ci(pr): land the E2E path gate advisory instead of blocking The E2E suite is currently failing every scheduled run on main — 22 of the last 22 — so making verify depend on it would block any PR touching tests/e2e/**, including the PRs that fix the suite. This PR's own run reproduced that: 3 of 12 shards failed on specs unrelated to it (agent-session resume, Jira linking, plugin containment, terminal artifacts). So the job runs and reports on E2E-path PRs but is left out of verify.needs for now. The detector, the tests/playwright. path fix, and the contract tests are unaffected — those stand on their own and were the substance of the review. Flipping to blocking is a three-line change once the suite is green; the exact wiring, including why the skipped allowance must sit outside the strict loop, is recorded on verify's Require-successful-checks step. The contract test pins the advisory choice so it reads as deliberate rather than as the unwired-gate bug it originally caught, and still fails if the path filter, the strict-loop coverage, or the config path regress. --------- Co-authored-by: Wooseong Kim Co-authored-by: Orca * fix(orchestration): repair version-skewed run schemas (#11150) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(i18n): translate automation cadence labels (#11068) The cadence picker now resolves Hourly, Daily, Weekdays, Weekly, and Custom cron through the renderer i18n catalog, with corrected zh/ko/es translations. Supersedes #10044 (thanks @innocarpe) and #10045 (thanks @fsdwen). Fixes #10043 * chore(i18n): drop orphaned cadence catalog keys (#11154) Removes the lowercase hourly/weekly/custom entries from the AutomationSchedulePicker catalog node in all five locales. They were superseded by keys derived from the rendered labels in #11068, and the extractor only adds keys, so they stayed behind unreferenced. Follow-up to #11068. * test(cli): lock CLI-compatible timeout parse contract (#11206) parsePositiveSafeIntegerNumericText mirrors the CLI's own Number() coercion on purpose: text like `600000.000000000000001` is the budget the CLI will actually wait on, so rejecting it here would leave the relay and SSH kill timers shorter than the CLI's and cut the request short. Document that and pin it with regression cases. * fix(diff): stop file-tree navigation remounting combined diffs; make tree resizable (#11088) * feat: add Trae CLI as a supported TUI agent (#10763) * feat: [AI-GEN] add Trae CLI as a supported TUI agent Closes #10579. Wire trae-cli into the desktop and mobile agent catalogs following the same integration pattern as other CLI agents (e.g. Ante, Devin): - src/shared/types.ts, tui-agent-config.ts: register 'trae' with detectCmdAliases (traecli/trae-agent) and argv prompt injection, matching trae-cli's `trae-cli [prompt]` contract. The CLI's own third documented alias `ta` is intentionally excluded — too generic a 2-letter name to use as a PATH-existence detection signal without false-positiving on unrelated tools. - src/shared/trae-headless-command.ts: recognize `--print`/`-p` and `--output-format json|stream-json` as one-shot headless invocations (same shape as claude-headless-command.ts) so they aren't mistaken for a live interactive session. - agent-kind.ts, telemetry-events.ts, agent-status-types.ts, agent-type-label.ts, tui-agent-display-names.ts, tui-agent-permissions.ts (YOLO via trae-cli's own --yolo flag), tui-agent-selection.ts: standard per-agent registrations. - agent-catalog.tsx, agent-favicon-assets.ts, mobile/src/tasks/mobile-tui-agents.ts, mobile/src/components/mobile-agent-icon-assets.ts: catalog entries and bundled favicon (fetched from docs.trae.cn, required by mobile's offline-icon invariant test). - i18n: add the "Trae" label to all five locale catalogs (en/es/ja/ko/zh). - Tests: agent-process-recognition, agent-status, tui-agent-startup. Verified with `pnpm typecheck` (desktop + mobile), the relevant vitest suites (869 tests across 12 files, all green), oxlint (clean), and a real end-to-end launch of the actual trae-cli binary through Orca's pty.spawn IPC path (confirmed via the OS process table). * fix: [AI-GEN] point Trae catalog entry at the real CLI quick-start doc docs.trae.cn/cli (what the installed CLI's own --help text prints as its "User manual" link) soft-404s — the docs site restructured and the working page is docs.trae.cn/cli_get-started-with-trae-cli (confirmed by HTTP fetch: real page title "TRAE CLI 快速开始" vs the old path's "404 - 页面不存在"). Addresses CodeRabbit's homepageUrl review comment. * fix: [AI-GEN] detect Trae on traecli, not the ambiguous trae-cli name Per @AmethystLiang's review: the open-source bytedance/trae-agent project (MIT, ~12k stars) registers its own console script as `trae-cli` (pyproject.toml: `trae-cli = "trae_agent.cli:main"`), an entirely unrelated CLI with a different contract (`trae-cli run "task"`, `-p` short for `--provider`). Detecting on bare `trae-cli` would false-positive on that project's installs and break launch for anyone who has it instead of the actual TRAE CN CLI. - tui-agent-config.ts: detectCmd/launchCmd/expectedProcess -> `traecli` (TRAE CN's own installer symlinks this alias too, but the other project does not ship it). Dropped the `trae-agent` alias entirely — it's the colliding project's literal repo name, the highest false-positive string available. - agent-catalog.tsx: cmd -> `traecli` to match; faviconDomain -> `www.trae.cn` (bare `trae.cn` 404s on Google's favicon service; `www.trae.cn` is the product-root domain that actually resolves). - mobile-tui-agents.ts: faviconDomain -> `www.trae.cn` to match. - Tests updated: agent-process-recognition now asserts `trae-cli` and `trae-agent` are NOT recognized as Trae (regression guard against reintroducing the collision); tui-agent-startup updated for the new launch command. promptInjectionMode stays `argv` and the headless-command file stays as-is — both verified against the real TRAE CN CLI's actual --help output (pasted in the PR review thread), not assumptions. * refactor: [AI-GEN] share one print-mode headless matcher across agents trae-headless-command.ts was a rename-only fork of claude-headless-command.ts, and ante-headless-command.ts carried a third copy of optionName. Collapse both print-mode files into print-mode-headless-command.ts, dispatch from a Partial> table instead of an if-chain, and compress the Trae comments to the repo's one-line style. * fix: [AI-GEN] terminate Trae flag parsing before the positional prompt `traecli` is a Cobra CLI with subcommands, so an argv prompt starting with `help`, `config`, `-…` was dispatched as a subcommand or flag instead of being run as the task. Add `argvPromptSeparator: '--'` (same reason Grok has it), and stop the shared print-mode headless matcher at `--` so a prompt that reads like `--print` no longer drops the pane out of agent recognition. * docs: [AI-GEN] name both Trae CLIs explicitly in the detect-name comment Co-authored-by: Orca * docs: [AI-GEN] drop the vendor tag from the Trae union comment Co-authored-by: Orca * fix: [AI-GEN] guard the nullable startup plan in the Trae separator test Co-authored-by: Orca --------- Co-authored-by: 陈泽榜 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca * chore: remove orchestration structured-output draft design doc from repo root * fix(codex): re-confirm spurious shell readings before skipping the restart card (#11076) An account switch decides pane eligibility from a single cached inspectProcess read. When that read reports the pane's shell for a live Codex session, the pane silently loses its restart card - no error, no retry. Re-check shell readings on Orca-launched Codex panes with the existing fresh-scan confirmForegroundProcess before trusting them; only an affirmative codex answer flips the decision, so a genuine exit to the shell stays uncarded and unsupported providers keep today's behavior. * feat(dictation): add stop button and shortcut hint to Listening indicator (#11152) * feat: bound direct SSH reconnect fan-out and recovery (#11003) * docs: design for direct SSH reconnect fan-out Capture the implementation-ready plan for host-qualified, epoch-fenced SSH reconnect recovery after two rounds of multi-model LLM counsel review. * docs: reconcile SSH reconnect fan-out design * docs: close reconnect design consistency gaps * feat: implement bounded direct SSH reconnect recovery * fix: bound direct SSH retry settlement * fix: harden direct SSH reconnect authority * fix: preserve split SSH retry ownership * fix: preserve SSH split continuation authority * docs: record final SSH reconnect validation * fix: preserve SSH authority through retained and detached state * fix: retain SSH authority across delayed split mounts * fix: close SSH authority recovery gaps * fix: fence stale SSH transport replacement * fix: serialize SSH target teardown * fix: settle SSH teardown failures before reconnect * fix: retire failed SSH reset sessions * test: reconcile current main E2E contracts * fix: close direct SSH reconnect review gaps * fix: fence stale SSH reconnect side effects * fix: close final SSH reconnect lifecycle gaps * test: stabilize current-main reliability gates * test: prove plugin navigation containment * test: make plugin navigation oracle authoritative * test: make plugin navigation oracle deterministic * ci: allow sharded e2e suite to finish * test: wait for runtime pane publication * test: classify pane readiness by error code * test: select close persistence terminal by tab identity * docs: mark reconnect implementation validated --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Improve translations for resource manager and related UI elements (#11205) * Improve translations for resource manager and related UI elements Standardize terminology ("daemon" vs "service"), complete missing translations, and refine wording across Spanish, Japanese, Korean, and Chinese locales for consistency and clarity. * Improve translations for resource manager and related UI elements * fix(test): update zh name-mode label expectation after translation fix The resource-manager translation pass correctly changed the Chinese "Name" label from 姓名 to 名称; update the localized options unit test to match so CI passes. * fix(native-chat): surface draft launch context in desktop and mobile chat composers (#9802) * fix(native-chat): surface draft launch context in chat composers Creating a workspace from a GitHub issue delivers the issue link only into the agent TUI's input buffer (argv prefill or startup paste), so the chat view showed no trace of it on desktop or mobile. Desktop: draft launches now seed an in-memory launch draft keyed by tab id (direct work-item launches, background GitHub work-item creates, quick-create composer, and new-tab draft deliveries). The chat composer adopts the seed once as its editable draft, declines permanently if the composer already has text, and drops an untouched copy when any user turn lands (the one-line TUI input means the prefill was submitted or deliberately cleared) or on its own send, whose existing input pre-clear retires the TUI copy. Mobile: the host publishes the draft as an optional launchDraft field on the mobile terminal tab snapshot (additive, no protocol bump) and the mobile composer adopts it with the same once-only/decline/resolve semantics. Mobile chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so a pending prefill cannot concatenate with the sent message. Completion seeding resolves the launch tab from the synced store tabs when the backend spawned the terminal and activation reports no primaryTabId. Split the Windows shell-quoting tests into their own file to stay within the max-lines budget. * revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch The libc binding fields and the @typescript-eslint peer re-resolution came from a local install, not from this change; mobile/package.json is untouched. * fix(native-chat): resolve launch drafts without trusting cross-host clocks The rule required a user turn stamped at or after the seed. Grok omits row timestamps, so a Grok launch draft never resolved; and the seed time is a renderer clock while the stamp comes from the executing host's JSONL, so a remote workspace whose clock trailed never resolved either. Both left the composer adopting an already-submitted prefill, which re-sends it as a duplicate turn. Resolve on any user turn that is not PROVABLY older than the seed (a launch draft's session starts with zero user turns), with the existing cross-host skew slack, plus a timestamp-free backstop for wider skew: a new tail user turn since the draft was first observed. "Load earlier" prepends, so it cannot move the tail and cannot over-resolve. Split out of native-chat-pending.ts to stay under the max-lines ratchet. * fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0] Two defects in the completion seed: - The tab was resolved by array position. buildStartupOpt returns undefined on the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no tab and the launchAgent guard was dead there. A repo with default terminal tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent, and then published it to mobile as THAT tab's launchDraft. Correlate on the backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId (which is the agent tab whenever the renderer owns startup); never tabs[0]. - Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree was empty at seed time and the seed was silently dropped for that whole host class. Defer to the first mirrored tab via the existing delayed-delivery queue, which now holds every pending delivery for a worktree instead of one (setup/issue commands and the seed both wait on the same first tab). * fix(store): evict nativeChatLaunchDraftByTabId on every teardown path The new map was absent from all four paths its sibling nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded entry is worse than a plain leak here because sync-runtime-graph keeps publishing it to mobile as that tab's launchDraft. * fix(native-chat): only seed single-line unsubmitted launch drafts The unsubmitted-delivery branch seeded on every draft delivery, which also caught the agent-session-fork path whose prompt is multi-line scraped context. The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a multi-line prefill cannot be fully cleared and its earlier lines would glue onto the next message. The GitHub work-item draft this feature targets is a bare issue URL, so narrowing costs it nothing. Also assert the composer retires the seed after a send — deleting that call previously failed no test. * fix(mobile): stop the chat pre-clear from wiping a just-pasted image The text write set clearInputFirst unconditionally. On the image path that Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image, so the agent receives the text alone while acceptSend still renders the thumbnail on the sent bubble — silent image loss. Desktop's image path clears exactly once, before the paste, and never again; mobile now matches: pre-clear only when nothing was deliberately pasted first. The image paste already leads with its own Ctrl+U, so a launch-draft prefill parked on the input line still cannot glue onto the message. Pinned at both levels: the controller test drives the real send hook and asserts clearInputFirst per branch, and the send module asserts the wire text carries no leading \x15. The image-attachments test injects its own baseSend, so it structurally could not observe this. * fix(mobile): hold the launch-draft prefill until the transcript settles session.tabs delivers launchDraft before the transcript read resolves, so the seed effect could run against an empty in-flight message list and miss the user-turn decline. Launching from an issue, submitting the prefill in the TUI, and never opening desktop chat (nothing else clears the host seed) then prefilled the mobile composer with the already-sent issue link — a send tapped before it retracted duplicated it to the agent. Thread the session's loading state through and skip the seed while the read is in flight. idle/waiting-session still seed: no session means no user turns. * fix(runtime): publish a launch draft to mobile only for the tab's own agent The publish had no agent check while the desktop consumer declines on mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so mobile could adopt a draft desktop refuses — seed for claude, never open desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with the Claude-era issue link. Align publish with the consumer. * fix(native-chat): take the launch-draft baseline only after the transcript loads The timestamp-free backstop snapshotted the transcript's user turns on first observation of the draft, which can happen while the read is still in flight and `messages` is []. A pane bound to a session that already had user turns then backfilled above that zero baseline with a different tail id, so clause 2 resolved and silently dropped the seed — the launch context never appeared, and the feature no-oped for exactly the panes it was meant to serve. Clause 1 was already correct there (that history is provably older than the seed). Gate baseline capture and resolution on the transcript read settling, the same shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged list is empty anyway, and a pane with live appends is never reported 'loading'. Also restore clause 1's short-circuit: it scans with .some() again and only allocates the user-turn list when falling through to the backstop. NativeChatView sat at exactly the 400-line cap, so the composer's two launch-draft props are now spread from the hook result they already mirror. * fix(native-chat): reject multi-line launch drafts inside the seed helper The single-line guard lived in deliverLaunchPromptToAgentTab, so the two other seeding entry points (worktree create, direct work-item launch) bypassed it — and every Linear launch is multi-line by construction ("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI with Ctrl+U, which kills to start of LINE, so those earlier lines stay parked to glue onto the next message. * fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs The runtime-owned deferred path fell back to tabs[0], which the module's own docstring forbids: with repo default tabs ("dev server", "logs") the seed lands on a tab running no agent, where mobile withholds it and desktop's agent check ignores it — the feature is silently dead for that create and the entry leaks until tab close. The queue entry is consumed before delivery, so there is no retry to fall back on; accept the first mirrored tab only when it is the worktree's only one and so unambiguously the agent's. * fix(mobile): treat a launch-draft-only session-tab frame as a change mobileSessionTabEqual's terminal branch never compared launchDraft, and the route keeps `prev` when tabs compare equal — so a publish whose only delta is the draft appearing or retracting was discarded and never reached the composer. Live QA passed only because agentStatus happened to change in the same frame. MobileSessionTab's terminal variant did not declare the field either (the controller read it through the structurally wider MobileNativeChatTab), which is why TypeScript never flagged it. * fix(mobile): judge a launch prefill only from its own settled transcript Two ways the drafts hook was reading a transcript that was not the active chat's: - transcriptLoading came from `status`, a plain useState written by a passive effect declared before the drafts hook. On the commit where the tab identity changes it still holds the previous tab's value, so the guard was off on exactly the render that seeds: first entry saw status 'idle' with an empty list and seeded an already-submitted link, and a tab switch declined the new tab's prefill from the old tab's turns. The session hook now tracks the identity its messages describe and reports transcriptLoading until they agree; the retire effect gates on it too. - Leaving chat view nulled launchDraft while draftKey stayed the same, which the hook could not tell from a host retraction — it declined the prefill permanently, so peeking at the terminal dropped the context. The controller now passes the raw field plus an explicit chatActive flag, and both effects hold their state when the tab is not on chat. The controller wiring was previously unasserted: replacing both props with constants left all 795 mobile session tests green. * fix(native-chat): keep the launch-draft baseline across a transcript reload baselineKey went null whenever the transcript was loading, and the null branch DISCARDED an already-valid baseline taken from a settled read. It was then re-taken from the fuller list, swallowing the very user turn that resolves the draft — so a stale prefill gets re-adopted as a duplicate turn. Key the baseline on draft identity alone and gate only the capture. session.status is also not a truthful read-in-flight signal: a live 'working' hook outranks 'loading', so the guard could be off over an in-flight empty list. Expose the read phase itself and gate on that. * test: cover the launch-draft reducers and the sync-key skip gate Every consumer test injects the three launch-draft reducers as bare vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left 2609 tests green — while in the app the composer would resurrect the prefill after every manual clear. canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either: when it skips, the sync key is never even built, so the existing getRuntimeMobileSessionSyncKey case cannot catch its removal. * fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref react-compiler rejects reading or writing a ref during render. Adjust the held baseline with the sanctioned render-time setState instead, keeping the local copy so the render that first sees a settled transcript resolves against it. * fix(mobile): carry the transcript identity in the session read state react-doctor flags the separate loadedIdentity state as an extra render for a derivable value. Hold status alongside the identity it describes in one state written by the subscription effect, so transcriptLoading derives from it. * test(native-chat): assert the readPhase contract without the hook-status race The test asserted status === 'working', which depends on liveStatusOverride winning over ambient transcript state — green locally, red under CI load. The contract is that readPhase stays 'loading' once live content unmasks status, so assert exactly that; it still fails if readPhase derives from status. * fix(mobile): derive pre-read chat status instead of writing it from the effect react-doctor's no-derived-state-effect flags idle/waiting-session/loading being set in the subscription effect: all three are pure functions of the props. Derive them during render and keep state only for the genuinely async outcome, tagged with the identity it describes. The tag now gates `messages` too, so a just-switched tab never sees the previous tab's transcript at all rather than seeing it behind a loading flag. * fix(mobile): drop a settled chat read once its subscription is torn down The settled outcome was only ever replaced by a newly arriving frame, so any effect re-run that landed back on an already-settled identity resurfaced it over a list the same effect had just cleared: 'ready' with no messages and transcriptLoading false. Toggling out of chat view and back hit this every time (the agent goes null, then returns), flashing the "start a chat" empty state over a real conversation and opening the launch-draft seed's decline check on an empty transcript. A reconnect did the same via the client dep. Identity and client are the effect's only inputs, so tagging the read with both and dropping it during render when either moves covers every re-run. * fix: pr-bug-scan validated finding from #6471 (#6512) * fix: address pr-bug-scan validated finding from #6471 stripTags now removes real markup (closing/self-closing/attributed/custom tags) outside the allow-list; only bare identifier-glued generics like Array are kept. Blocks svg/center/custom leak a * fix: address pr-bug-scan validated finding from #6471 stripTags now removes real markup (closing/self-closing/attributed/custom tags) outside the allow-list; only bare identifier-glued generics like Array are kept. Blocks svg/center/custom leak a * fix(mobile): harden markdown preview tag stripping * fix(mobile): preserve angle-bracket prose while stripping tags --------- Co-authored-by: orca-bug-scan-bot Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459) * feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable directories. Copying `node_modules` per worktree is slow and duplicates disk, and each worktree's install then diverges. Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of gitignored directories that are symlinked (shared) into every new local worktree, so one install serves them all. Adds to, never replaces, the per-user Worktree Shared Paths setting. `createWorktreeSharedPaths` uses a new 'share' materialization mode that always symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give each worktree an independent node_modules and defeat the point; 'link' and 'copy' behavior are unchanged. Entries must exist as gitignored directories in the primary checkout; absolute paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips this, as it does symlink paths and `.worktreeinclude`. Closes #10451 * fix(worktrees): keep worktrees deletable after sharing a directory A directory-only ignore rule (`node_modules/`, the common spelling) matches the primary checkout's real directory, so the shared directory resolves and gets symlinked — but it never matches the worktree's symlink, so Git reports that link as untracked. Deletion only tolerated the per-user shared paths, so every worktree in such a repo became permanently dirty: the clean preflight threw "uncommitted or untracked changes" and `git worktree remove` refused without --force. Feed the configured `orca.yaml` shared directories into the same tolerate-and-unlink machinery the per-user shared paths already use, at both deletion call sites. The names are read unfiltered, since the create-time resolver drops exactly the entry deletion needs most. * test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for ../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports lazily, so this only stays green because no runtime test configures a repo with worktree.sharedDirectories — the first one that does would fail on a mock resolution error rather than on its own assertion. * fix(source-control): don't count shared symlinks as uncommitted changes A directory-only ignore rule (`node_modules/`) matches the primary checkout's real directory but never the worktree's symlink, so Git reports the shared link as untracked for the life of the worktree. That made every affected worktree read as dirty: a phantom row in the diff view, and Create PR blocked with `blockedReason: 'dirty'` telling the user to commit an entry they cannot commit, because it is a symlink Orca created. Status and the review-creation preflight now drop untracked entries that are both declared shared (per-user shared paths or orca.yaml sharedDirectories) and actually symlinks on disk. Both conditions are required, so a regular file at a declared name, or a symlink nobody declared, still counts as user work. The decision fails closed: anything not positively identified stays dirty. The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes are compared raw rather than C-quoted, with a parser that consumes the origin field a rename emits instead of reading it as its own record. Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks would pull APFS cloning, and its child_process dependency, into the status graph. SSH is unaffected and left alone — remote worktree creation skips the symlink and shared-directory passes, so a remote worktree never has one. * fix(source-control): wire shared links into local status * fix(worktrees): resolve the status repo once and reject uncollapsed shared paths `git:status` resolved the registered worktree's repo twice per call — once inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link lookup — walking every repo's worktree meta on a polling path. `apps/./web` also survived `sharedDirectories` normalization: `resolve()` collapses it when the symlink is created but Git reports the collapsed path, so every later comparison misses and the link reads as permanent untracked work. Also stop resolving shared links for SSH repos in review creation: `repo.path` names a path on the remote host. Adds the missing wiring coverage for review creation and runtime status, plus the untracked-only conjunct in both filters — all four were mutation-verified to leave the suite green before these tests. * test(worktrees): pin the resolver-to-status seam for shared directories The resolver's output and the status filter were only tested apart — status used a hardcoded `['node_modules']`. Feed the resolved directories back through `getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever returned a differently-spelled path can no longer leave the link showing as a phantom untracked row. * fix(worktrees): try a directory junction before a symlink on Windows A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary Windows user got EPERM, the per-path catch logged and continued, and the worktree came up with no shared directory and no signal. A directory junction needs no privilege, and the rest of the codebase already uses one for win32 directory links. The symlink stays as a fallback rather than being replaced: a junction cannot target a UNC path, and a WSL project's repo lives behind one, so replacing it outright would trade the local-volume bug for a WSL regression. Safe for the removal path either way — Windows reports a junction as both a symlink and a directory, so the `isSymbolicLink()` unlink that runs before `git worktree remove` still fires and still refuses to follow it. * fix(worktrees): keep NUL bytes and tolerated links out of the removal error The removal preflight switches to `git status --porcelain -z` whenever it has shared links to tolerate, then attached that raw stdout to the error. `.trim()` does not strip interior NULs, so the message reached the user as `?? node_modules?? precious.txt` — raw control bytes, and it named the shared link, the one entry that is not the user's work and cannot be committed away. Parse the NUL-delimited output once and use it for both the clean verdict and the error text, so the two can never disagree about what blocks removal. The `-z` switch stays: it is what keeps paths with spaces or non-ASCII names comparable against the configured entry. * chore(worktrees): drop stray reformatting and note why the SSH guard exists Committing the merge staged 792 files, so lint-staged ran the formatter across all of them and rewrapped three renderer files that were already unformatted on main. Nothing was lost — they were byte-identical to main ignoring whitespace — but they showed up in the pull request as unrelated changed files. Restored to main's exact bytes. Committed with --no-verify on purpose: the pre-commit formatter is what introduced the rewrapping, so letting it run again would simply reapply it. Every check it would have run was run by hand instead — lint, typecheck, and the IPC and source-control suites all pass, and the three restored files are expected to fail a format check because that is main's current state. Also records why the connection guard on the shared-link lookup is not dead code: the remote dirty check ignores those paths, so the guard's only effect is avoiding a stray local read and the bad cache entry it would leave behind. * refactor(source-control): drop a scan-everything guard and freeze the cached list The dirty check built a filtered array only to read its length, so it always scanned every status record; asking whether any record is untracked stops at the first one and reads the same either way. The cached shared-directory list was also handhanded out by reference, so a caller that mutated it would corrupt every read for the rest of the cache window. Marking the return readonly prevents that at compile time; copying on return would work too but would allocate on the status-polling path, and there is exactly one caller, which only spreads it. * fix(mobile): exclude proxy fake-ip addresses from pairing QR (#10498) * fix(mobile): exclude proxy fake-ip addresses from pairing QR Clash/mihomo TUN interfaces in 198.18.0.0/15 were enumerated as pairing candidates and could become the default QR endpoint. Phones then retry an unroutable address forever. Drop those addresses from the pickable list (#10404). * refactor(mobile): keep fake-ip filtering local * test(mobile): cover fake-ip range boundaries --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * Bug floating workspace shortcuts route to main w (#10433) * fix(floating-workspace): route panel shortcuts to the floating panel, not the main window Floating-workspace close/index keyboard shortcuts leaked to the main window behind the panel. Route them through the floating panel across all four keydown layers via an atomic focus signal, panel-owned indexed switching with a tri-state outcome, an event-target-aware close guard, and a floating-scoped guest IPC bridge. Changes A-E and findings F2/F3/F4/F6/F7/F8/F9/F11/F-adv/F-dl/F-feas. Co-authored-by: Orca Co-authored-by: feelgom Co-authored-by: Wooseong Kim * fix(review): clear stale floating-panel reclaim intent on panel close The module-singleton reclaim intent (F3) is armed at an emptying-close but only consumed by the visibleFloatingItemCount->0 effect. If a concurrent tab-create keeps the panel from reaching 0, the intent stays armed and could survive to a later empty-panel mount and steal keyboard focus. The !open release effect now clears it (defense-in-depth), matching the outside-pointerdown/window-blur paths. Flagged by 4 review personas (correctness, adversarial, julik-races, maintainability). Co-authored-by: Orca * test(floating-workspace): cover L1 index-chord yield and deferred-close reclaim-arm timing Two additive R2-review tests for the #10288 floating-workspace shortcut routing change set: - createMainWindow: assert L1 yields the initial indexed-switch chord (tab-index and worktree-index) to the floating panel without preventDefault or dispatch, and contains held-key auto-repeats in main (preventDefault, no dispatch). Closes the untested Change B (F4) path. - FloatingTerminalPanel: assert an emptying, panel-owned close whose closeTerminalTab defers/cancels (onClosed never fires) leaves the reclaim intent unarmed, so no later empty-panel mount can reclaim focus for a close that never happened. The prior mock fired onClosed unconditionally, so this arm-timing (F3) branch was uncovered. Co-authored-by: Orca * fix(review): resolve round-1 findings F-1..F-6 - F-1: re-derive panel emptiness from live store at arm time; clear stale reclaim intent on repopulating create so an unrelated later close can't consume it and steal keyboard focus from the main workspace. - F-2/F-5a: single-source the panel's non-creation shortcut claims via matchFloatingWorkspacePanelShortcut(); shared isTerminalPaneCloseChord() predicate for L2/L3; App.tsx gate + both FloatingTerminalPanel call sites now call the SSOT so index/rename/max-min ownership can't drift. - F-4: L2 keydown gate is event-target-aware (matches L1 yield) so an L1-yielded chord is still consumed during a transient panel blur. - F-5b: export clearReportedFloatingFocusCache() + reset it in test setup. - F-5c: split floating-workspace-item-actions.ts into focus-reclaim + guest-bridge modules (AGENTS.md file-naming). - F-6: trim verbose design-code comments to single-line WHY. Co-authored-by: Orca * fix(floating-workspace): remove finding reference labels These internal review labels (F1–F7) and change identifiers were used during development and are no longer needed in the code. * fix(floating-workspace): preserve reclaim for deferred dirty closes Dirty editor closes defer to the save dialog and complete asynchronously. The reclaim-arm check must survive the queue and execute when the file leaves— otherwise the next Cmd/Ctrl+T misses the floating panel entirely. Also resolve browser guest page ids to their owning workspace for correct routing. * perf(floating-workspace): single-pass shortcut match and stable listeners Three hot-path cleanups with no routing behavior change: - Match each keydown once. App.tsx's yield gate now calls one matchFloatingWorkspacePanelChord instead of scanning the creation table and the chrome table separately, and the panel splits dispatch into resolveFloatingPanelShortcut + applyFloatingPanelShortcut so the surface keydown preflight shares its resolution instead of re-matching. - Pin the window-capture and guest-bridge listeners to [open] by reading the live closures (tab order, activate, close helpers, dispatch) through a ref, so a tab switch or reorder no longer re-subscribes them. - Cache the per-tab TerminalPane ref callback so a parent render stops detaching and re-attaching every pane handle. Creation chords stay target-gated and chrome chords stay ungated, matching the two matchers the combined one composes. Pre-commit hook bypassed: config/oxlint-react-doctor.json fails to parse against this worktree's stale node_modules (oxlint 1.71.0 / react-doctor 0.2.10 vs the pinned ^1.75.0 / 0.9.1) for any file. oxlint, oxfmt --check, tsc, the max-lines ratchet, and the targeted vitest runs were run manually. * fix(floating-workspace): keep TerminalPane ref callback identity stable The per-tab ref callback cache deleted its own entry on detach. After a same-id remount (key is tab.id + generation) React detaches the old element *after* the new render already read the cache, so the delete dropped the entry that render had just written — every later render minted a fresh identity and forced React to detach/re-attach the pane, the churn the cache existed to prevent. Move the cache into terminal-pane-handle-registry.ts: detach clears only the handle, attach re-arms the cache entry, and dead tab ids are pruned from an effect keyed on the live tab list. Unit-tests cover attach/detach identity stability — FloatingTerminalPanel.test.tsx's React mock discards effect deps and ref identity, so component tests can't catch this class of bug. Also softened the combined-matcher comment: App.tsx's old `||` already short-circuited, so that call site buys drift-safety, not fewer scans. Gates: tsc (web), oxlint, oxfmt --check, max-lines ratchet, 332 focused vitest tests. Pre-commit hook bypassed: config/oxlint-react-doctor.json fails to parse against this worktree's stale node_modules (oxlint 1.71.0 + react-doctor 0.2.10 vs the pinned ^1.75.0 / 0.9.1) on untouched files too. * fix(floating-workspace): pure registry init for react-doctor Replace null-guarded ref mutation during render with useState lazy init so CI check:react-doctor:changed stops failing on FloatingTerminalPanel. * fix(floating-workspace): drop unused registry type import Satisfies oxlint no-unused-vars after pure useState registry init. Local pre-commit react-doctor config fails on stale node_modules; CI has current plugins. --------- Co-authored-by: Orca Co-authored-by: feelgom Co-authored-by: Wooseong Kim * fix(skills): trust the updater's lock when a run installs content newer than the bundle (#11220) skills update installs source-repo HEAD, which routinely runs ahead of the revisions a shipped build bundles. The post-run re-scan hashed that content 'unrecognized' (the registry has never seen it) and the verdict counted it as a failure — so a clean update reported "The update didn't finish / Updated 0 of N", and Retry repeated the false failure forever because the CLI now no-ops (lock == source). The 'newer-known' escape hatch never fires: the generator always points the manifest at the registry's newest snapshot, so no observed content can hash to a revision newer than the bundle. The verdict now computes the git tree sha of the observed bytes (a port of the generator's hashing, verified byte-for-byte against git write-tree and against every shipped skill's manifest gitTreeSha) and accepts an unrecognized placement when that sha equals the lock's skillFolderHash: the lock is the CLI's own record of what it installed, so disk matching lock means the command did its job — the bundled registry simply has not seen that revision yet. Half-written bundles (sha mismatch), unreadable copies, removed skills, degraded aliases, and outdated copies at the lock hash all still fail. * fix(skills): stop the scan issue budget evicting a read failure (#11221) The per-scan issue budget kept an issue only when it explained a candidate or truncated the walk. Neither set intersects the attention set, so 'io-error' — the sole reason a plugin-cache scan can raise "Needs attention" — was droppable. Once 16 ordinary issues filled the budget (16 'outside-root' vendor symlinks is an install shape the scan itself documents as normal), a later read failure was evicted for a generic 'issue-limit' row that raises neither attention nor truncation, and the dialog headline read "All installed Orca skills are up to date" over a path that could be hiding a stale copy. Attention issues now outrank the budget, capped at a small reserve so an adversarial tree of unreadable folders cannot pin one issue per folder. * Stop relaunching creation-time agents on workspace activation (#10647) * fix(activation): stop relaunching the creation-time agent on workspace activation Activating a workspace with zero renderable tabs launched the agent it was created with, unprompted and in approval-bypass mode. Navigation is not consent to start a process: the same fallback fired from post-delete focus handoff, the jump palette, keyboard cycling, CLI/relay activation, and notification clicks. The mechanism was superseded. #1814 added it when relaunching the created agent *was* the resume feature; #4706 later added real provider-session resume six lines above and left the fallback in place. What remained fired whenever a workspace had no renderable tabs -- including when nothing had ever slept -- and reported itself as `request_kind: 'resume'` while resuming nothing, discarding any resumable session a plain tab close had already purged. No caller depends on it. All seven intent-carrying callers pass an explicit `startup` on the branch where they intend a launch, and every no-startup branch either declined an agent, already has one running (host `didSpawnStartup`), or is this same defect arriving over IPC. Drops the now-orphaned imports, retargets the stale comment in launch-work-item-direct that cited reopen-relaunch as the reason to persist `createdWithAgent`, and moves the WSL default-args quoting assertion to launch-agent-in-new-tab, whose launch path still resolves those args. Regression tests are revert-sensitive -- all four fail if the fallback returns. * test(activation): name the relaunch regression tests after what they reach Three tests were named after scenarios they never invoked, which is the failure mode that lets a coverage gap read as closed. - The "host-originated" test's `notifyHostRuntime: false` is inert here: both gates resolve through `isWebRuntimeSessionActive`, false with no runtime environment seeded, so it was byte-identical to the plain reopen test. It no longer claims to cover the host `didSpawnStartup` leg, which lives in main and is unreachable from this layer. - The "post-delete focus handoff" test never deleted anything and never touched `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in active-worktree-focus-after-delete.test.ts, which locks out any opts. - The activate/close loop resets state instead of calling `closeTab`, so it does not exercise the sleeping-record purge its comment claimed. Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the fixture extracted for exactly that state, which its inline copy had drifted from by hardcoding a POSIX repo path. `preflight` is dropped from the launch-work-item-direct comment: the trust preflight reads the create-time argument (worktree-remote.ts), not the persisted meta. Removal safety and ownership do read the field and remain accurate. Renames the ported quoting test to what it pins. Under vitest's node environment `navigator.userAgent` carries no "Windows", so platform resolution bails before the WSL branch and the WSL preference is inert — the real coverage is single-quote escaping of user-configured agentDefaultArgs. * transfer large terminal history seeds across bounded protocol messages - Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations - Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit - Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery - NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors * fix(daemon): drop held output when teardown checkpoint fails to serializ When a final snapshot checkpoint fails to serialize (returns retryable), the pending output records must not be appended later—doing so would splice them over the seq gap left by the failed snapshot, defeating gap detection. Drop the records and retry the checkpoint instead. * Bump daemon protocol version to 30 * Bump daemon protocol version to 30 * fix(ai-vault): resume a bridged Codex session under the selected account's home (#11224) * fix(ai-vault): resume a bridged Codex session under the selected account's home The account session bridge hardlinks every rollout into each per-account CODEX_HOME, and vault dedup keeps the lexicographically-smallest alias, so Resume could pin an inline CODEX_HOME naming a peer account — running the session under that account's auth.json and quota. At resume time the owning host now substitutes the selected account's home when it holds the same rollout at the same sessions-relative path, declining on any uncertainty so resume degrades to today's behavior instead of failing. * fix(ai-vault): repin dropped sessions without a cwd instead of resuming under the wrong account The drag payload only carried sessionCwd when session.cwd was truthy, so a null-cwd codex session dropped onto a pane silently fell back to the prebuilt command - which pins the wrong account's CODEX_HOME, the exact defect this PR eliminates on the other resume surfaces. - Serializer always sends sessionCwd (null when the session has no cwd), so absence now only means an older-serializer payload. - The repin rebuild accepts a null cwd (the builders already omit the cd prefix), matching the sidebar Resume/Copy paths which repin regardless of cwd. - An unrepinnable payload (absent sessionCwd) now fails loudly with guidance instead of silently resuming under the wrong account's home. * fix(codex): keep the stale-pane prompt when two accounts share a label (#11228) The startup sweep asks main which panes are stale, and main answers by account id. The renderer then threw that away: it resolved both ids to labels and let the store's A -> B -> A collapse compare the strings. Two accounts can share a label — doAddAccount has no duplicate-email check, so one OpenAI login used in two ChatGPT workspaces gives both the same email, and a failed roster read collapses every account to 'Codex account'. Either way the notice was deleted for a pane that really is running under the account the user switched away from. The sweep then made it permanent: it marked every stale pane notified, including the ones whose notice had just been dropped, and a notified pane is suppressed for the rest of the session. Relaunching cleared the set but the deletion recurred, so the prompt never came back and the pane kept running on the other account's auth.json and quota, silently. Carry the account ids into the notice and decide on them, falling back to labels only for callers that have none; report which panes were left holding a notice so a dropped one cannot claim suppression. The prompt also names the ChatGPT workspace when that is what tells two same-email accounts apart, which is what the two duplicated getCodexAccountLabel copies now share. * fix(ssh): resync after watcher terminal retry (#10691) * fix(ssh): resync after watcher terminal retry * fix(ssh): resync after watcher terminal retry - Coalesce repeated recovery resyncs within 5s to reduce SSH refreshes during link flaps - Abort in-flight watcher installs when a replacement provider registers, preventing duplicate watchers from old and new transports - Clear resync state when removing watcher snapshots or on provider change to prevent stale retry timers * fix(ssh): resync after watcher terminal retry Avoid logging spurious warnings when a remote watcher is already closed or suspended. Move the console.warn call in handleRemoteWatcherTerminalError() to after the early-return checks. Refactor createSender() in tests to properly simulate the destroyed event for better coverage of retry-cancellation behavior. * fix(macos): explain the TCC prompts, and surface Full Disk Access only to users macOS is prompting (#9756) (#9910) * fix(macos): add a Full Disk Access nudge to reduce recurring TCC prompts (#9756) macOS shows the "Orca wants to access other apps' data" (kTCCServiceSystemPolicyAppData) prompt and it can keep reappearing. The reappearing loop is not a fixable app bug: it is TCC identity churn — an unsigned local rebuild mints a new code identity each build, so macOS treats each as a new app — and Orca's other-app reads are already gated behind opt-in settings or explicit user actions. The durable remedy for the population we can help (release users) is Full Disk Access, a superset macOS grant that stops these prompts for a stable identity. Surface it with an ambient, dismissable sidebar card that reuses the existing developer-permissions IPC. macOS-only; probes FDA status at most once per renderer session (the probe itself reads protected data, so it must not repeat on focus/remount); "Open System Settings" opens the Full Disk Access pane; permanent localStorage dismissal. * fix(macos): stop the FDA nudge promising macOS will stop asking The card said Full Disk Access makes "macOS stop asking", but the grant covers this app while terminals are spawned by the detached PTY daemon (daemon-init.ts forks execPath with ELECTRON_RUN_AS_NODE + detached:true, reparented to launchd), which macOS treats as its own TCC identity. A user who followed the card would grant FDA and still be prompted from terminals. Scope the claim to reducing prompts and name the terminal caveat. * fix(macos): drop stale focus refreshes in the FDA nudge refreshFullDiskAccessStatus() applied whichever getStatus() round-trip resolved last. Rapid blur/focus puts several in flight, so an earlier pre-grant 'unknown' landing after a newer 'granted' un-hid the card and also wrote 'unknown' into the module-level session cache, re-nagging a user who already has Full Disk Access for the rest of the session. The adjacent FullDiskAccessSetupPrompt already guards this with a refresh sequence; mirror it here. Also unmount React roots in afterEach: clearing document.body left them mounted, leaking each test's window focus listener into later tests. * test(macos): unmount the StrictMode FDA nudge root between tests The afterEach unmount added in 5a0f717 only covers roots created through renderNudge(). The StrictMode probe test builds its own root, so it was never unmounted and its component stayed live for the rest of the file. Today that component has no window focus listener, so nothing breaks; add a CTA click to it and the same contamination 5a0f717 fixed comes back — the two tests after it see extra getStatus() calls and fail. Register the root so the fix covers every mount site. * fix(macos): attribute the FDA prompts to agent activity, not Orca's own reads The card said the prompts happen "when this copy of Orca reads protected app data", but Orca's own reads are small and gated; #9756's trigger is agent find/grep sweeps into ~/Library/Containers, which macOS bills to Orca because Orca is the responsible process for every terminal child. Blaming Orca reads as an accusation and hid why FDA works at all — the grant attaches to Orca rather than to each churning child binary. Name agents as the trigger, keep the "reduce" hedge and the terminal caveat, and drop the "this copy of Orca" dev-build hedge that cost a clause. Assert the causation wording so it can't silently regress. * fix(macos): explain the TCC prompts on the settings row, drop the sidebar card The sidebar nudge added in 344d466b was premised on FDA being reachable "only inside onboarding". It isn't: Settings > macOS Permissions has had a full-disk-access row all along (searchable), the Setup Guide hosts the same prompt from both a settings pane and a re-openable modal, and the sidebar already links to that modal via the "Onboarding checklist" entry. The card added a fifth affordance to the same sidebar that already had the fourth, so it bought prominence rather than access - shown to every macOS user without FDA, most of whom never hit #9756. Keep the part that was actually new. The settings row still described the prompts as something projects and worktrees trigger, which is the same misattribution the card carried: the reads come from the agents Orca runs, and macOS names Orca only because it is the responsible process for every terminal child. It also never mentioned that the grant has to cover Orca Helper, or that the preserved daemon keeps stale TCC state until restart. Non-English catalogs get the English string as a placeholder; the bootstrap translators key their cache on the English value, so a changed string is re-translated on the next run. * feat(macos): nudge Full Disk Access only after macOS repeatedly prompts The FDA hint is only worth showing to users macOS is actually prompting. tccd emits one AUTHREQ_PROMPTING line per consent dialog it displays, carrying the service and both identities, so a narrow log-stream predicate detects the real thing without correlating across lines or guessing whether a dialog appeared. Verified against a captured dialog: the predicate matched 1 line out of 1436 TCC lines in ~28s, because routine preflight checks - the overwhelming majority of TCC traffic - do not emit it. Count dialogs where Orca is the responsible process, persist across launches, and tell the renderer on the third one. The event separates the accessing binary from the responsible app, which is the crux of #9756, so the toast can name the tool that triggered it rather than blaming Orca generically. One toast per user, with a permanent opt-out; it deep-links to the FDA row in Settings > macOS Permissions rather than restating the guidance. macOS-only: the watcher no-ops elsewhere, the web client stubs the API, and the child is killed on before-quit since log stream ignores a closed stdout. * test(macos): pin the platform so the TCC watcher tests exercise the darwin path start() is darwin-gated, so on Linux CI it no-opped and the stream/kill assertions passed vacuously against a watcher that never spawned. Pin process.platform per the existing convention (shared/secure-file.test.ts), and cover the gate itself with an explicit non-darwin case. * fix(macos): start the TCC watcher from app bootstrap, not the window wiring attachMainWindowServices is called directly by its own unit test, so wiring initTccPromptNotice there made `vitest src/main/window/` spawn real `log stream` children that outlived the run - two orphaned watchers were left behind by a single test session. Only the IPC handler registration stays there; the spawn moves to the real app bootstrap in index.ts, which tests never execute. Verified: running the suite that leaked now leaves the watcher count unchanged. * fix(macos): clarify repeated permission notice * fix(macos): keep TCC notice lifecycle safe * fix(macos): retain pending TCC notice delivery * fix(macos): acknowledge TCC notice delivery * fix(macos): release failed TCC notice claims * fix(macos): retry transient TCC notice display * fix(macos): contain TCC notice IPC failures * fix(macos): harden TCC notice renderer lifecycle * fix(macos): contain TCC notice dismissal failures * test(macos): satisfy promise executor lint * fix(macos): detect helper-attributed TCC prompts * fix(macos): align TCC watcher lifecycle and helper identity * perf(macos): defer TCC log reader until first paint * fix(macos): recover deferred TCC watcher startup * fix(macos): recover TCC watcher from deferred quit * fix(macos): localize recurring file access notice * fix(macos): preserve TCC watcher and localized guidance * fix(macos): avoid duplicate TCC watcher recovery * fix(macos): wait for locale before TCC notice * perf(macos): isolate TCC notice subscriptions --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(release): stop packaging plugin authoring examples into app.asar (#11087) * fix(release): stop packaging plugin authoring examples into app.asar electron-builder's `files` is an all-negation list, so its default `**/*` packs anything without an explicit `!` entry. examples/ arrived with the plugin system in #8549 and never got one, so 1.4.160-rc.3 shipped examples/plugins/hostile-panel/panel.html — the adversarial fixture the panel containment tests point at, complete with its fetch-exfiltration probe — plus hello-orca, inside every user's app.asar. Verified against the installed 1.4.160-rc.3 artifact, not just the config. The two orchestration design docs landed at the repo root in the same span and shipped the same way; fold them into the existing root-doc negation. Neither has a runtime consumer: bundled plugins ship via extraResources from resources/plugins/launch/, which is already excluded from the asar for exactly this reason. * test(release): assert the examples exclusion through the real file matcher The added case mapped each negation to a bare top-level token, so it passed under '!examples/README.md' — a pattern that still ships the whole tree. Drive app-builder-lib's FileMatcher instead so the assertion matches the test name, and pin the root anchoring so the negation cannot grow into '!**/examples'. * fix(updater): resume background checks after a local build session ends (#11223) A local-build check (Option+click "Check for Updates" on macOS) pins activeUpdateSource to 'local' for the rest of the process. The 'update-available' success path never restores it, and runBackgroundUpdateCheck early-returns on it, so every wake-from-sleep check, window-focus daily check and nudge poll became a no-op once a local build reached 'available'. The one-shot automatic timer fired into that early return and nothing re-armed it, so the scheduling chain died too and lastUpdateCheckAt froze. Restoring the source when 'update-available' fires would break the flow the user just started — the pending download still needs the local feed and allowDowngrade. Instead the release source is restored when the user closes the offered card, which main previously never learned about, and only while status is exactly 'available': downloadUpdate() flips status to 'downloading' synchronously before it calls into electron-updater, so this cannot fire once a download is under way. The automatic timer now re-arms when a check is deferred rather than launched, so a deferral can no longer end automatic checks for the process lifetime. * feat(dashboard): add agent status search board (#11042) * feat(dashboard): add agent status search board * fix(dashboard): keep idle controls reachable * chore: drop merge-only formatting drift * fix(dashboard): compare sparse subagent snapshots safely * fix(dashboard): satisfy settings handler lint * fix(dashboard): address review feedback * fix(dashboard): complete search and localized status copy * fix(dashboard): pad active filter row * fix(dashboard): keep idle control in board settings * fix(dashboard): source filters from workspace state * fix(dashboard): clarify PR and MR status filter * fix(dashboard): preserve review and board parity * perf(dashboard): stop re-sending repo icon data URLs on every republish (#11089) * perf(dashboard): stop re-sending repo icon data URLs on every republish #11012 put repo icons on the dashboard snapshot keyed by repoId. Image icons are data URLs capped at MAX_REPO_ICON_DATA_URL_LENGTH (400KB) and every repo contributing a card ships one, while the snapshot republishes up to 4x/sec (PUBLISH_THROTTLE_MS = 250) for as long as the pop-out is open. Icons change about never, so that structured-clones megabytes per second across the window boundary for bytes the pop-out already has. Publish the map only when it actually changed, comparing by reference since icons come off immutable store repo records. The two paths where the pop-out could be starting from nothing — it opened, or it mounted and asked — still force a full send, so the retained copy can never be the only one. The pop-out keeps the last map it was given when a republish omits the field. An explicitly empty map still clears, so removing an icon works. repoIconsByRepoId was already optional on DashboardSnapshot and isDashboardRepoIcons already returns true for undefined, so the main-process validator needed no change. * fix(dashboard): keep repo icons in the main-process snapshot cache The bridge now omits an unchanged repoIconsByRepoId from republishes, so the cached snapshot main replays to a mounting pop-out could be icon-less, blanking the board's repo glyphs until the forced publish landed. Carry the last map into the cache; the forwarded payload is unchanged. Also covers the forced full sends (open, reopen, snapshot request) that no test exercised. * test(dashboard): pin the icon omit on the throttled trailing republish * fix(dashboard): keep the popout bridge effect off the react-doctor gate The changed-code quality gate reports react-doctor findings that overlap added lines, and effect-needs-cleanup spans the whole publish effect — so this PR's edits inside it turned a pre-existing false positive into a red static-analysis check. Hoisting the store subscriber leaves the effect owning one disposable; behaviour is unchanged. * docs(dashboard): correct why watchSnapshotInputs sits outside the effect The effect owns four disposables (offOpenChanged, offRequested, the store unsubscribe, and the trailing timer), not one. State the real reason the subscribe is hoisted so nobody inlines it back and re-reds the gate. * test(dashboard): pin that the bridge subscribes only while the pop-out is open The lazy wiring exists so an enabled-but-closed pop-out costs nothing — a live subscriber would rebuild a cross-worktree snapshot on unrelated store writes. Nothing pinned the unsubscribe on close. * fix(runtime): surface desktop RPC startup failures (#11037) * fix(runtime): surface desktop RPC startup failures * fix(runtime): isolate RPC failure telemetry * fix(runtime): satisfy the changed-code quality gate and kill vacuous dialog tests The `no-floating-promises` label span covers the whole `app.whenReady().then()` callback, so adding lines inside it made a long-standing finding overlap changed code. `void` is the linter's own suppression; no `.catch()` on purpose. The startup-failure tests were vacuous: mutation runs showed the wait-for-show deferral, the destroyed-window guard, the `closed` companion event, listener cleanup, the cause walk, the cycle guard, and the truncation bound could all be deleted with every test still green. The "not called yet" assertion ran before any microtask, so it passed either way. * test(runtime): de-brittle the desktop RPC-failure source assertions Anchoring the slice on the full destructure and matching the whole dialog call expression made an innocuous rename break the test with a cryptic 'expected -1'. Match the shape that is actually the contract instead. * test(runtime): repair the silently-unbounded desktop startup slice The desktopEnd anchor comment lost a word in 98b00d3a64, so indexOf returned -1 and slice(start, -1) covered index.ts to EOF. Moving the dialog call to a path that never runs at startup still passed. Anchor on code instead, and assert both bounds so a future reword fails loudly. * test(runtime): bound the attach anchors in the startup ordering slice Round 3 bounded the desktop pair but left attachStart/attachEnd unguarded in the same test: deleting the PTY startup barrier from attachMainWindowServices() and breaking the rateLimits.attach(window) end anchor still left the case green. * test(startup): bound the last two unguarded slice anchors in this file Rounds 3 and 4 fixed the desktop and attach pairs; two instances of the same class survived in the same file, both proven vacuous by mutation: - it #3 never bounded readyEnd. Renaming the `pairing:` payload key makes it -1, widening readyPayload from 372B to ~52KB. Moving the reconciliation status out of the serve-ready payload (its whole point) but leaving it later in index.ts then kept all 6 cases green. - it #2 bounded desktopWindowStart against reconciliationStart rather than serveEnd. An earlier `Promise.resolve(openMainWindow())` steals the anchor, collapsing desktopStartup to '' while every existing guard still passes, so its only assertion — a negative — succeeds against an empty string. Both mutants now fail. `src/main/ipc/pty-startup-barrier-ordering.test.ts:11` has the same latent shape; left alone as out of scope for this PR. * fix(runtime): keep walking the cause chain past an unmapped code getErrorCode returned the first code it found, so an outer wrapper carrying an unrecognised code masked a nested EACCES/ENOSPC and classified it unknown. Only a mapped code ends the walk now; every other input classifies as before. Unreachable today (writeSecureFile rethrows raw fs errors with .code intact), but the classifier's job is surviving whatever error shape reaches it. * fix(runtime): tell the user what to fix, not just to restart The dialog's only advice was "Restart Orca to try again", which is true for address_in_use and wrong for the rest: permissions, a full or read-only disk, and a missing data folder all survive a relaunch, so the user restarted, hit the same failure and had no next step. Route the error class we already compute into the copy so each cause names the thing the user has to change. Guidance and telemetry now derive from the same classifier, so they cannot drift apart. * fix(runtime): guide users through long RPC paths * fix(runtime): avoid false window listener warning * fix(runtime): guard destroyed window before web contents * fix(agent-status): prevent ghost sidebar row on completed split-pane detach (#10698) * fix(agent-status): prevent ghost sidebar row on completed split-pane detach Detaching a done-state split pane into its own tab migrated the agent paneKey from oldTab:leaf to newTab:leaf. useRetainedAgentsSync only saw the old key vanish and, finding no suppressor, resurrected it as an unclickable duplicate sidebar row (and inflated the worktree count). Plant a one-shot retention suppressor on the source key during transferAgentPaneAuthority, but only when the source actually held a live agent, so a suppressor is never leaked for a pane that had none. Fixes #10675 Co-Authored-By: Claude * fix(agent-status): annotate suppressor record type and condense retention comments Type the migrated retentionSuppressedPaneKeys as Record so a computed-key `true` isn't widened to boolean, which broke the web typecheck. Also condense the retention rationale comments per review. Co-Authored-By: Claude --------- Co-authored-by: Claude * fix(macos): prevent stale UI surfaces after wake (#11226) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * feat(main): record main-thread hangs so we can measure them (#10256) A deadlocked main thread never crashes, so it leaves no crash report and no artifact — incidence has been unmeasurable (n=1 confirmed, macOS 26.5.1, FB24004458 / electron#52437). This forks a plain-Node watchdog sibling under ELECTRON_RUN_AS_NODE that survives the deadlock, listens for a 2s heartbeat, and after 45s of silence writes a marker to userData. The next launch consumes it, records a durable crash breadcrumb, and emits a main_thread_hang_detected telemetry event carrying unresponsive_ms and self_recovered. Observes only — it never kills or relaunches the parent. A true positive recovers nothing force-quitting wouldn't, while a false positive would SIGKILL a live main thread mid-write. self_recovered counts exactly the stalls such a killer would have gotten wrong, so recovery can be built on evidence if the field numbers justify it. macOS-only, packaged-only (ORCA_HANG_WATCHDOG_FORCE=1 to test), with sleep-gap suppression and idempotent shutdown on will-quit. * fix(skills): stop the skill review dialog contradicting the badge that opens it (#11128) * fix(skills): stop the skill review dialog contradicting the badge that opens it A skill whose only fault was an edited copy or one Orca could not read turned the setup-rail badge amber and offered Details — and Details opened a dialog headlined "All installed Orca skills are up to date." over an empty list. The badge says something is wrong, the dialog it points at says nothing is. The grouping only returned skills with an out-of-date copy, so those two states produced no row and the summary fell through to the all-clear headline. Include a skill when a copy needs attention as well, using one shared predicate so the badge and the dialog cannot disagree again. A plugin's own copy of a same-named skill stays out: that is the vendor's, not the user's drift. * test(skills): pin that a routine outdated copy raises no attention marker * fix(macos): show TCC notice after first prompt (#11243) * fix(skills): tell the user how to fix a skill the updater cannot converge (#11248) Re-lands #11129, which was merged into #11128's branch rather than main and so never reached main. Content is identical to the reviewed and live-QA'd head ac5ec5b0b0 (1775d83cf6 + ac5ec5b0b0, minus the intermediate merge). * fix(perf): correct three 07-27 perf regressions (#11234) * fix(perf): correct three 07-27 perf regressions Traversal capacity cap no longer scales with worker concurrency (#11026). retainWorkspaceSpaceScanEntry charged a traversal-wide entry counter, so N workers each holding a listing multiplied the live charge. At concurrency 48 a 48x2,100 tree (100,848 entries) hit the 100,000 cap while 100x1,500 (150,100 entries, 50% more) passed, and scanLocalWorktree treats the capacity error as terminal, reporting an intact worktree as "Unavailable" with sizeBytes 0. The cap is now per directory listing -- the only quantity fixed by directory shape -- restoring the invariant docs/workspace-space-scan-resource-bounds.md already states. Aggregate live retention stays bounded by the unchanged 64 MiB byte cap. Note: releasing each entry's charge at dispatch (the originally suggested fix) was measured and does not help; the peak is set at admission, before any entry is dispatched. Repo image icons are no longer fully base64-decoded on every snapshot publish (#11012). sanitizeRepoIcon reached decodeBase64Prefix, which sized its buffer to the whole payload to read a 24-byte header, running synchronously inside ipcMain.handle at a 250 ms throttle. Validation is now memoized on source+src in a BoundedMap. Measured for 10 icons x 256 KB: 37.34 ms -> 0.67 ms per publish. One over-long card label no longer discards the entire snapshot (#11012). isDashboardSnapshot was all-or-nothing and dashboard-popout returned early with no log while replaying lastSnapshot, so `orca terminal rename --title "<1025+ chars>"` froze the pop-out board on its last good paint with nothing surfaced. Labels are truncated at the producer, the validator drops only the offending card, and both the rejection and the drop are logged. The bound now lives in the shared snapshot contract so producer and validator cannot drift. Co-authored-by: Orca * fix(perf): charge a scan listing's parent path once, not per entry The 4.1 fix made the entry cap per-listing but left the 64 MiB byte cap charging parentPath.length for every entry in a listing. Because a listing's entries all share one parent-path string, that multiplied the path by the directory's width, so the byte cap measured checkout depth rather than live heap. The reported symptom therefore still reproduced at the production default limits: 48 x 2,100 @ concurrency 48 raised a capacity error once the worktree path passed ~58 characters, while the same layout at concurrency 1 succeeded. The shipped regression test could not see this because it passes maxRetainedBytes: Number.MAX_SAFE_INTEGER, disabling the only cap still in play. Measured at a real 65-char worktree root, 3 of the report's 4 documented layouts still failed. The parent path is now charged once per listing, with its first entry, so an empty listing strands no charge. Per-entry overhead is unchanged at 512 B + name, which still dominates the estimate, so the OOM protection the original PR added is preserved. Adds a production-default-limits case covering the report's layouts under a deep root, plus an assertion that a short and a deep root reach the same verdict -- the path independence docs/workspace-space-scan-resource-bounds.md requires and which no existing test enforced. Co-authored-by: Orca * fix(perf): prove the icon cache by decode count, not wall clock The caching test asserted a per-publish millisecond budget, which failed on CI at 5.64 ms against a 5 ms ceiling. Any threshold flakes on a loaded box, so count real sanitizeRepoIcon entries instead: 10 repos x 20 publishes is 200 icon checks against exactly 1 decode. Added cases pin the cache key (payload and source both re-decode; a cached image verdict never answers for an emoji) and that a rejection is cached too. Also drops budget.entries, which the per-listing cap left as a traversal-wide counter no check reads -- exactly the shape a future guard could reintroduce the concurrency bug from. Co-authored-by: Orca * fix(dashboard): bound the project filter label the whole board rides on #11042 added snapshot-level filterOptions whose project labels are repo.displayName -- the same unbounded source this PR already bounds for card.repoName, but one level up where dropping a card cannot recover it. An over-long project name would fail isDashboardFilterOptions and take the entire snapshot with it, which is the exact frozen-board failure the per-card drop was added to end. Workspace-status labels are already capped at 32 by workspace-statuses.ts, so only projects needed this. Co-authored-by: Orca * fix(dashboard): disambiguate the repo icon cache key The memoization key joined `source` and `src` with a space, but the sanitizer's base64 pattern admits whitespace inside a valid `src`. A rejected icon can therefore split the same concatenation differently and inherit an accepted icon's cached verdict, reaching the pop-out's `` without ever being sanitized. Length-prefix the source. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales (#11241) * fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales 7.1 ui.set rejected whole preference payloads on enum drift. The new AssertNoMissingKeys guard is key-only, so it could not see that LegacyWorktreeCardProperty omitted 'cli' (in DEFAULT_WORKTREE_CARD_PROPERTIES) or that rightSidebarTab omitted 'workspaces'/'pr-checks' and every plugin tab. UiUpdate is .strict(), so one bad value failed the entire batch and silently dropped sidebarWidth/groupBy/sortBy/filterRepoIds riding the same debounced write. Both enums now derive from the shared unions, AssertNoMissingValues catches value drift by name, and UiUpdate drops an unknown value instead of rejecting the batch around it. Unknown KEYS still reject. 7.2 The SSH shell-ready fallback moved from first-output to spawn, so a remote shell needing >1.5s to prompt got the bracketed-paste startup command before readline armed it, with no recovery afterward. The short deadline now applies only once output proves the shell is talking; a silent-since-spawn shell gets a longer budget and still delivers eventually. 7.3 The project picker armed in rank order but rendered in section order, so with a folder group present the BOTTOM row was armed on open and Enter created the workspace in the wrong place. Row keys now derive from the same sections that render. The folders bucket also gains the recent-exclusion guard the projects bucket has; that duplicate was unreachable, so this is symmetry, not a live bug fix. 7.4 setBrowserPageZoomLevel now compares before writing, so a pane reasserting a level the host already holds no longer emits a redundant host-wide HostZoomMap write. The user-applied level also moved to a module-level map keyed by page id: the guest webview outlives its React pane, so the pane-local ref re-seeded from the shared Settings default on every remount and let a later default retroactively hijack an already-zoomed tab. See PR notes on the part of this finding that could not be fixed as prescribed. 7.5 A non-null sessionId short-circuited the live-work escape hatch, forcing 'loading' over hook 'working' and rendering an idle pane mid-turn: Send instead of Stop, no typing indicator, no streaming preview. Status stays 'working'; the empty-transcript loading SURFACE moves to selectNativeChatViewState, which keeps 7.6 #10770 merged from a base predating #8549, reverting 182-187 translated strings per locale to English (es 182, ja/ko/zh 187) plus en.json's recipesHelp. Restored by script, only where the English source is unchanged between the two shas, so later legitimate edits are preserved: 0 keys added or removed, every value sourced from 97e4776dfe, and the four other English-source changes since 7.7 Match highlighting indexed by UTF-16 code unit but rendered by code point, so an emoji-named folder showed marks one glyph late. Cosmetic. Co-authored-by: Orca * fix(ssh): keep fast startup delivery on the short fallback deadline The 15s no-output budget added for the shell-ready fallback was applied to every SSH launch, including 'fast' delivery. Fast delivery waits for no marker and pastes nothing prompt-sensitive, so it gained a 10x startup delay for nothing. Co-authored-by: Orca * fix(rpc): generalize the ui.set value-parity guard to every shared key Naming worktreeCardProperties and rightSidebarTab left the next field to drift exactly as unguarded: dropping 'pr-status' from groupBy typechecked clean. Check the value domain of every shared key instead, against z.input (what a client may send) rather than z.infer (post-transform). Also move the pure mergeNativeChatLiveSession suite beside the module it covers; the hook's test file owns an IO harness and was at the max-lines cap. Co-authored-by: Orca * fix(i18n): re-apply only the locale strings still reverted at HEAD #10770 merged from a base predating #8549, so its stale locale copies overwrote ~185 already-translated strings per locale back to English. A present catalog value always beats the English translate() fallback, so those strings render English with nothing to signal the loss. Since that finding was written, #11205 and other upstream translation passes independently re-covered most of ja/ko/zh. Replaying #8549's catalogs wholesale would now overwrite that newer work, so this re-applies a key ONLY where all of the following hold at origin/main: it was translated at 97e4776dfe, #10770 reverted it, its English source is unchanged since, and no upstream commit has touched it since the revert. es 182 ja 32 ko 17 zh 55 Everything else is left to upstream. Verified zero upstream translations reverted: every changed key still matches its #10770 value at main. Keys upstream deleted are not resurrected, and keys whose English source was edited since are skipped as legitimate source changes rather than reverts (this is what keeps zh CPU on #11205's deliberate "CPU" over #8549's "中央处理器"). Key count and order are unchanged in all five catalogs. en.json's own recipesHelp was reverted by the same stale base and no upstream commit has touched it since, so it is restored to match the live source at EphemeralVmsPane.tsx:252. * fix(rpc): keep null in the ui.set value-parity guard NonNullable stripped null as well as undefined, so dropping .nullable() from a `| null` field passed the guard while still rejecting the batch at runtime -- the exact drift class the guard exists to catch. Proven: making visibleWorkspaceHostIds non-nullable typechecked clean before, now errors by name. Also pins the 15s silent-shell budget so it cannot silently shrink back toward the short deadline. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(skills): stop calling the updater's own install a modified copy (#11249) After a successful headless update the CLI installs source-repo HEAD, which legitimately runs ahead of any shipped bundle. The scan classified those bytes 'unrecognized', so the row went amber ('may be modified… remove it') seconds after our own Update button ran, and the advice looped: remove + reinstall lands the same newer content. Scan half: a canonical/alias placement whose observed git tree sha equals the updater lock's skillFolderHash is the CLI's own install, not a user edit — reclassify it 'newer-known'. Display half: 'newer-known' is recognized official content ahead of this build with nothing to fix, so it no longer marks the copy blocked. Eligibility is deliberately unchanged: ahead of the bundle means there is nothing this build can update to, and offering one risks the provably-unperformable update (#11110) when source HEAD still equals the lock. Copies whose sha does not match the lock, copies with no lock entry, same-name copies outside the placements the command writes, and plugin-cache behavior all stay flagged exactly as before. * fix: stop notification loss, credentialed cache reuse, and clipboard clobber (#11230) * fix: stop notification loss, credentialed cache reuse, and clipboard clobber Mobile catch-up (#8591): fetchMissed swallowed the RPC failure while deliverLive kept advancing and persisting lastDeliveredSeq, so the next successful catch-up asked from above the abandoned range and the desktop cut it. Sessions are module-scope, so an unchanged epoch never resets it. Quarantine the watermark at the last contiguously-delivered seq and hold it there until some later catch-up actually drains — not just one retry. A batch cut short by a teardown quarantines at the last event it settled. Jira attachment cache: currentEpoch summed two independent counters, so a site at siteEpoch 1 read the same value before and after a global clear. The mid-flight guard passed and re-inserted credentialed image bytes that "disconnect all" had just purged — resident for the process lifetime since pruneExpired has no timer. One monotonic ticker, compared by max. Web copy fallback: the handler registered in the capture phase, so xterm's bubble-phase listener overwrote text/plain with the terminal selection afterwards; served was already true, so the copy reported success. Every Orca copy affordance over plain HTTP (Copy Pane ID, Copy Path, commit SHA, PR URL) pasted the terminal selection. Bubble phase with stopImmediatePropagation. Covers the secure-context retry branch too, which shares the same helper. * fix: roll back the persisted watermark on catch-up failure; cover stopImmediatePropagation Adversarial review of a98d7f4d5d found two gaps. 1. The quarantine clamped only writes made AFTER the failure. getMissedSince waits up to 30s, so a live event routinely persists a higher seq while the request is still outstanding; that value stayed on disk, and the next launch read it back and resumed past the abandoned range -- the original bug, reached through a restart. quarantineCatchUpWatermark now re-persists the clamped seq, so the stored value never outlives the gap it guards. 2. web-clipboard-copy-terminal-selection's second test registered its "late" document handler BEFORE the fallback's, so it lost on registration order alone and stopImmediatePropagation was never exercised -- the test passed with that line deleted. Bubbling reaches the document before the window, so a window-level listener is what actually requires it. * fix(mobile): mark a notification seen only once its show lands A pre-marked seen key made a rejected show unrecoverable: the next catch-up re-fetched the seq and the dedup guard dropped it, and the first later event to drain the batch lifted the quarantine past it. Also contains the rejection so it does not escape the un-awaited 'ready'/live handlers as an unhandled rejection. Co-authored-by: Orca * test(web-clipboard): pin stopImmediatePropagation with a same-target handler Both existing cases passed with plain stopPropagation, and with the listener back in the capture phase — neither half of the fix was actually pinned. The window-level clobber is on a different target, so stopPropagation suppresses it too. Registering the clobber on the document, ordered after the fallback's own listener, is the only shape stopPropagation cannot cover. Addresses the review comment posted after the last commit. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(plugins): close four trust-boundary holes in the plugin system (#11232) * fix(plugins): close trust-boundary holes in the plugin system Move five security decisions to their chokepoints rather than leaving them enumerated at individual call sites. - Kill-list revocation reaches content packs: PluginContentPackRegistry now takes an isKilled predicate and intersects it with any caller-supplied approval, so a killed plugin's VM recipes can no longer reach spawn(..., { shell: true }) through either reconcile() call site. - Bound kill-list generatedAt to a 24h future skew at the parse chokepoint. A far-future timestamp previously made every genuine later list look "older" and disabled revocation permanently, persisted across restarts. - Protect the whole auto.components.settings.Plugin* translation subtree instead of an enumerated prefix list, so language packs cannot forge the consent provenance badge or rewrite install-error security copy. - Resolve manifest panel icons by own-key only; "constructor"/"__proto__" previously yielded non-component prototype members that crashed the right sidebar to its error boundary. - Give panel liveness frames a reserved control budget so a panel that saturates its action budget can still answer the watchdog. Co-authored-by: Orca * fix(plugins): keep the kill-list future bound off the cache read path The schema-level generatedAt bound re-judged the on-disk cache against the device clock at every launch, so a client whose clock ran behind the last genuine publication discarded its whole cached kill list and started with zero revocations. Move the bound to the two fetch chokepoints instead. Co-authored-by: Orca * fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU Review follow-ups on the trust-boundary fixes: - The reserved liveness lane had a per-window count equal to the ping interval, so a panel's own pong-shaped traffic could spend it and drop the next genuine reply — reintroducing the starvation the lane exists to prevent. The lane is now size-bounded only; rate stays bounded because every pong is also charged to the data budget. - Only schema-valid pongs take the lane now, so near-miss pong-shaped junk cannot drain it. readPanelPongId replaces the zod parse on this guest-controlled path (a rejected safeParse allocates an issue list, ~90x the accepted-path cost) and is pinned to the schema by a parity test. - Re-read the kill list inside approveAtomically: approvedKeys is snapshotted before an awaited verification phase, so a plugin killed during that wait could still publish VM recipes and language packs. - Assert the curated icon resolves to FileText; the old equality also passed when both sides fell back to Plug. Co-authored-by: Orca * fix(plugins): match zod's safe-integer bound in the pong reader readPanelPongId used Number.isInteger, but zod's .int() rejects anything above 2**53-1, so pingIds like 1e100 took the reserved lane the schema would have refused. The parity test never probed that boundary. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(orchestration): reject legacy mail acknowledgment (#11227) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * release: v1.4.160-rc.5 * fix(opencode): use cross-platform data directory (#10362) * fix(opencode): use cross-platform data directory * fix(opencode): honor in-memory database override * fix(opencode): harden database discovery coverage * test(opencode): reproduce Windows session discovery --------- Co-authored-by: OrcaWin Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Fix Windows setup sequencing wrapper quoting (#8806) * fix(setup): correct Windows sequencing wrapper quoting * test(setup): preserve spaced Windows batch paths * refactor(setup): dedupe PowerShell encoder, clarify wrapCmd comment Route the Windows setup-sequencing and Hermes startup planners through the shared renderer-safe encodePowerShellCommand instead of two verbatim btoa copies, and make that shared encoder renderer-safe (Buffer is unavailable in the sandboxed renderer where both planners also run). Reword the wrapCmd comment so it describes the current single-outer-quote behavior instead of the old quote-doubling bug. * test(setup): cover Windows metacharacter paths * fix(setup): keep Windows runner paths out of cmd source * test(setup): preserve Windows setup failures * docs(setup): explain safe cmd path handoff --------- Co-authored-by: OrcaWin Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(settings): keep local WSL settings scoped to the desktop host (#9635) * fix(settings): scope local WSL settings to the desktop host * fix(settings): verify local WSL capability ownership * fix(settings): respect capability host ownership * fix(settings): isolate paired host capabilities * fix(settings): key web capabilities to paired host --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(updater): report releases still being published (#8914) * fix(updater): distinguish releases still publishing (#8869) * fix(updater): preserve verified releases during probe outages (#8869) * test(updater): expect publishing copy for perf checks (#8869) * fix(updater): keep transport failures out of publishing copy (#8869) * fix(updater): preserve channel and feed fallback semantics (#8869) * test(updater): prove feed and asset failure boundaries (#8869) * test(updater): cover unavailable manifest probes (#8869) * fix(updater): fence publishing copy to stable releases (#8869) * fix(updater): preserve nudge deferral across release channels (#8869) * fix(updater): retain benign nudge handling for probe outages (#8869) * test(updater): preserve channel and transport proof fidelity (#8869) * test(updater): model asset HTTP status in feed fixtures (#8869) * fix(updater): preserve legacy prerelease probe handling (#8869) * test(updater): cover unavailable publishing-window nudge retention (#8869) * test(updater): prove publishing retry and channel cases (#8869) * fix(updater): preserve truthful readiness states * fix(updater): type release preflight failures * fix(updater): keep probe outages truthful * fix(updater): keep not-ready diagnostics neutral --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(orchestration): sanitize legacy formatted JSON (#11263) * fix(orchestration): sanitize legacy formatted JSON * fix(orchestration): harden legacy message formatting --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): declare happy-dom so terminal-webview tests run standalone (#11238) mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts request the happy-dom vitest environment, but happy-dom was only declared at the repo root. The mobile suite resolved it by walking up into the root node_modules, so `cd mobile && pnpm install && pnpm test` fails with ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install happens to be present. * Fix WebSocket fallback for reserved Windows ports (#7185) * Fix WebSocket fallback for reserved ports * fix(runtime): narrow reserved-port fallback --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): accept WebSocket pairing addresses (#9912) * fix(mobile): accept websocket pairing addresses * fix(mobile): align manual pairing address validation * docs(mobile): correct custom address grammar comment * fix(mobile): enforce pairing endpoint size limit * fix(mobile): reject canonical IPv6 wildcard addresses * fix(mobile): handle unscannable pairing offers * fix(mobile): reset custom address dialog on close --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Support Windows drives in the remote host filesystem picker (#7439) * Support Windows drives in the remote host filesystem picker The remote picker was locked to the system drive on Windows hosts: the breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were treated as filter text, so projects could only ever be created on C:. - Server: answer host-root browses ('/') on win32 with the mounted drives instead of resolving to C:\. - Client: recognize drive-anchored input (M:\, M:/, m:) as path mode, resolve segments from the normalized drive root, and make joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root returns to the host root (the drive list). Fixes #7438 Co-Authored-By: Claude Fable 5 * Document why joinDrivePath uses a literal backslash Review feedback suggested path.win32.join, but the renderer bundle imports no Node builtins anywhere and runs sandboxed, so path.win32 is not available here. The backslash targets the remote Windows host regardless of client OS; say so at the call site. Co-Authored-By: Claude Fable 5 * Complete Windows drive browsing over SSH * fix remote Windows drive browsing * fix(ui): key remote breadcrumbs by path --------- Co-authored-by: Claude Fable 5 Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(explorer): refresh tree on create/rename with case-tolerant cache keys (#10392) * fix(explorer): refresh tree on create/rename with case-tolerant cache keys Windows watchers can emit paths whose casing differs from the worktree dirCache key, so create events never refreshed. Also apply rename events immediately by refreshing the parent listing instead of ignoring them. * fix(explorer): reconcile Windows update-only creates * fix(explorer): bound watcher update reconciliation * fix(explorer): index watcher cache paths * fix(explorer): avoid expanded directory rescan * fix(explorer): batch watcher subtree purges * fix(explorer): preserve Windows drive roots --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(build): stop `pnpm -r` from crawling the mobile workspace (#11291) Co-authored-by: Orca * fix(dashboard): give the agent preview terminal a real pane's keyboard (#11015) * fix(dashboard): give the agent preview terminal a real pane's keyboard The dashboard's preview terminal is a bare xterm, not a pane, so it never ran `resolveTerminalShortcutAction` — its only custom key handler covered copy/paste and IME. Ctrl+Backspace therefore fell through to xterm's default `\x08`, which readline binds to backward-delete-char: one character instead of a word. Route the preview's keys through the pane's own shortcut policy, so word and line kills, Option chords, modified Enter, and scrollback chords encode identically. Pane-scoped verdicts (splits, search, focus) are swallowed rather than passed to xterm, which would send e.g. Ctrl+Shift+D as a bare Ctrl+D. The policy needs three things the preview could not see: - kitty-protocol flags — mirrored locally from the same PTY output stream - the PTY's execution host — bytes follow the host, not the client OS, so a new `DashboardCard.terminalInput` profile is derived in the main renderer (the only one holding the store) and relayed to the pop-out - host terminal options — the ConPTY backend and the kitty withhold now apply Also brings the emulator itself up to a pane's: Orca's Unicode 11 width shim (replayed CJK/emoji/ZWJ laid out wrong without it), Windows Ctrl+Alt chord repair, user font/cursor/line-height/word-separator/sensitivity settings, ligatures, the TUI wheel multiplier, lazy Arabic shaping, clickable links, and the IME candidate anchor — extracted from pane-lifecycle so both surfaces share one implementation. The in-window drawer built its snapshot from a slice subset, which would have degraded the new profile to client-OS defaults there; it now reads the full store non-reactively. * fix(dashboard): enumerate pane-scoped chords instead of a default case The switch-exhaustiveness gate rejects a `default` over the shortcut-action union — it would let a newly added action be swallowed silently instead of forcing a decision at the preview's boundary. * fix(dashboard): preserve native shortcuts and PTY host routing * chore: keep merge scope limited to dashboard * perf(dashboard): avoid full-store copies for terminal profiles * fix(dashboard): validate terminal input profiles at IPC boundary * fix(dashboard): sync preview terminal refs on commit, not during render react-compiler rejects ref writes in the render pass; every reader is an event handler or a post-await continuation, so a commit-phase sync is equivalent. * test(dashboard): cover the three seams that relay the host-input profile Reverting any of them left every suite green: the dialog's terminalInput prop (the only reader of DashboardCard.terminalInput), the drawer's hand-threaded store slices, and the pop-out's republish triggers. Each new assertion was mutation-checked against its source line. * fix(dashboard): follow the WSL host for a preview terminal's byte routing The card resolver handed resolveTerminalInputHostPlatform a transport with no getLocalSessionMetadata, so a WSL pty on a Windows client resolved to win32 while its own pane resolves linux — Shift+Enter would then encode CSI-u where the pane sends alt-enter. Mirror the pane transport's own gate. * fix(dashboard): republish on every slice the host-input profile resolves from The compare set covered 4 of the ~11 slices that decide a card's execution host, so a change to the rest (folder workspaces, project groups, the runtime catalog, detected worktrees) never triggered a publish. On a quiet board there is no later publish to heal from, and the pop-out — which cannot re-derive the profile — keeps encoding bytes for the host the pty used to run on. * fix(dashboard): sync preview terminal refs on layout, not on a passive effect xterm's keydown is a native listener, so React never flushes a passive effect before it. A just-relayed host profile could therefore miss the next keystroke. * test(dashboard): pin the preview's replay-vs-live kitty scan The existing A/B passed either way: a lone CSI > u sets the same flags through scan and scanReplay. Redeliver the push across a snapshot and its replay so stack semantics would leave the TUI's single pop on a stale frame. * fix(dashboard): rebuild the drawer's snapshot on every host-input slice The in-window drawer read ~12 host slices through getState() while subscribing to none of them, on the premise that agent activity drives the next rebuild. A quiet board has no such rebuild: an SSH handshake completing with every agent idle leaves the preview terminal encoding bytes for the pre-connect host, and agentStatusEpoch only ticks on live status changes so it never heals. Watch the same set useDashboardPopoutBridge republishes on — each writer bails out when nothing changed, so the added deps are far quieter than agentStatusByPaneKey, which already rebuilds this memo on every status ping. The existing coverage mounted a second hook, which always recomputes; the new test re-renders the same hook after changing only sshConnectionStates. * fix(dashboard): key the preview by the user's terminal shortcut policy The preview passed 11 of the 12 inputs the pane's policy takes and let the 12th default to orca-first. Under terminal-first a remapped tab.close chord is meant to yield to the shell — Ctrl+W is a word-kill there — but the preview kept claiming it as a pane close and swallowed the bytes. * test(dashboard): pin the host-input profile to card snapshots only The count path main added in #11042 renders no cards, so it must not pay a per-pty host resolution on every agent-status tick. Both assertions run against a card that does have a live pty, so only the gate keeps the profile off. * refactor(dashboard): extract the board's client-host read The merge of main's label bounding pushed build-dashboard-snapshot.ts to 302 lines. The client's own platform facts are a distinct concept from the pty host each card keys against, so they move out rather than earn a max-lines bypass. * perf(vault): stop rebuilding session maps on every worktree switch (#11303) Switching worktrees rebuilt two ~500-entry maps in the Agent Session History panel because their memo deps included the active repo/worktree, which the maps never read; the worktree map also rebuilt ~530 path candidates (and re-normalized every root) per session, ~255k isPathInsideOrEqual calls per switch. - Drop activeRepo/activeWorktree from the sessionProjectById memo via buildAiVaultSessionProjectById, and activeWorktreeId from useAiVaultSessionWorktreeMap; 'current' is now stamped per row at read time (withAiVaultCurrentWorktreeStatus), so switches reuse both maps. - Hoist candidate building out of the per-session loop and precompute a normalized-root matcher per candidate, so map rebuilds on data changes are O(sessions + roots) normalizations instead of O(sessions x roots). NFC folding from #10841 is untouched; non-ASCII (NFD/CJK) matching is covered by new tests. Warm switch with the panel on All/500 drops 425ms -> 125ms on the full-scale rig (cold 491ms -> 193ms); panel-closed switches are unchanged. * test(vault): guard the workspace-switch regression #11303 fixed (#11311) #11303 removed the O(sessions x roots) path normalization from the session worktree map, but nothing fails if that shape comes back. Adds the two checks that were missing, plus the tool that would have caught it. - timing guard: 1200 worktrees x 400 sessions must build in <150ms. Verified fail-first — restoring the pre-#11303 per-session buildWorktreeCandidates call takes it to 238ms; it is ~15ms as merged. - path boundary: '/repo/alpha-sibling' must not be attributed to '/repo/alpha'. Hoisting the root normalization out of the loop must not degrade containment into a bare startsWith. Also adds tools/benchmarks/workspace-switch-paint-latency.mjs, which attaches over CDP and measures first-paint-after-click and max frame gap. The existing worktree-switch-responsiveness.spec.ts only times the synchronous click task, which stays ~1ms because the highlight is a direct DOM mutation — that is why a ~1s stall could ship without tripping a budget. On the affected build it read maxFrameGap p50=973ms. * fix(mobile): keep host action drawer close stable (#11306) * perf(vault): dedupe scope paths by key instead of rescanning (#11314) * perf(vault): dedupe scope paths by key instead of rescanning #11303 took the session maps off the workspace-switch path, but scope path derivation still follows the active worktree and stayed quadratic. addAiVaultWorkspaceScopePath deduped by re-normalizing every already accepted path on each insert, so accepting K paths cost O(K^2) normalize('NFC') calls — ~632k at 1124 workspaces. isAiVaultWorkspaceScopePathClaimed separately rescanned every live worktree per prior id, and runs twice per switch via activeWorktreePaths and scopePaths. - carry a Set of comparison keys alongside the paths, so each insert is one normalize plus one Set lookup - thread that accumulator from the workspace pass into the project pass rather than restarting deduplication against a plain array - build one comparison-path -> worktree id map for the claim check, keeping first-writer-wins to match the previous some() short-circuit deriveAiVaultScopeSessionPaths on a real 1124-workspace profile: 189.7ms -> 0.8ms. Output is unchanged, including ordering: verified against the previous implementation across 117 scenario/option combinations covering monorepo and mixed-repo layouts, priors both claimed and unclaimed, duplicate paths, NFD/NFC, WSL UNC, trailing and doubled separators, relative and blank paths, and four project-key shapes. Adds the first test file for this module: scope semantics (priors, claimed priors, cross-repo rejection, dedupe, NFD/NFC) plus a timing guard. Verified fail-first — the guard reports 220ms on the previous implementation. Path length is chosen deliberately, since normalize() cost scales with it and short synthetic paths understate the old shape. * fix(vault): make the claim check independent of worktree ordering Review catch on the first pass: keying claims by comparison path meant a duplicate path had to pick one owner, and picking the active worktree masked a real claimant later in the list. Concretely, with the active worktree also listed at its own prior path, the prior was reported unclaimed where the previous some() reported it claimed. Excludes the active worktree while building the set instead of comparing ids at read time, so any surviving entry is a claim by construction and ordering cannot decide the result. Adds a test over four orderings, verified fail-first against the previous commit. Also adds a timing guard for deriveAiVaultWorkspaceScopePaths, which the session-scope guard did not cover. Equivalence rerun against the pre-optimization implementation: 156 scenario/option combinations, identical paths and ordering. * perf(vault): look up resume worktrees through the shared index (#11317) Resolving a session's resume target scanned every worktree in every repo: two `Object.values(worktreesByRepo).flat().find(...)` calls, which also allocate a fresh 1124-element array each time, plus an equivalent `some()` walk. All three run per visible session row, so a panel render repeated them ~20 times. `getIndexedWorktreeMap` already exists for this and is WeakMap-cached on `worktreesByRepo`, so the index is built once per store snapshot rather than per call. `connection-owner-resolution.ts` already resolves worktrees this way. ~1.6ms -> ~0.002ms per render pass at 1124 worktrees. Net -4 lines. Behavior is unchanged: the map dedupes by id, which only diverges from `find()` when one id appears twice with different objects. Worktree ids are `repoId::path`, so a duplicate id within a repo array — the documented race the index was built for — refers to the same worktree. * fix(agents): make pane retention transfer-aware instead of suppressor-based (#11310) * fix(terminal): bound SSH & remote hidden-worktree terminal retention (C1) (#10625) * fix(terminal): park SSH worktrees like local ones (C1 retention, slice A) SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH worktree retained every pane forever (C1: renderer heap climbs to the V8 ceiling). SSH bytes transit local main — fact-mode watchers already cover them, and main keeps a headless model served over pty:getMainBufferSnapshot that the SSH reattach path never consulted. - isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded through both park verdicts, both selectors, watcher coverage, and the watcher start guard. Remote-runtime/fail-open/foreign/null unchanged. - Parked-SSH reveal paints from main's headless model (dimension-matched, ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a non-empty source==='headless' payload — never a blank/stale paint. - Kill switch: settings.terminalSshViewParking (default on). DESIGN.md records the approved plan and the H1 magnitude non-claim. Co-authored-by: Orca * fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B) Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the slice-A switch off) had unlimited retention: the parking cap/TTL only ever saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's panes forever. Retention is now memory-bounded, not eligibility-bounded. - terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over hidden worktrees ordinary parking can never evict; reuses the hot-retain ranking so last-active exemption, deterministic ties, and deadline-driven rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount would fresh-spawn and orphan the live shell). - Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto (darkness for uncoverable tabs is the accepted cost); buffers captured via the sleep-flow registry before the unmount render; retention TTL added to the recheck deadlines for budget candidates only. - Verdict stays out of its own effect deps; policy test asserts idempotence and time-monotone membership (flip-loop dwell regression). - Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on). Co-authored-by: Orca * fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C) The retention budget (slice B) must exempt worktrees holding fail-open or foreign-worktree ptys — a remount would fresh-spawn and orphan the live shell — which would leave that class unbounded again. Instead, past the same 45min retention TTL their hidden panes drop to the minimum scrollback tier (measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is gone by design, reveal restores the configured cap for future output). - terminal-hidden-scrollback-demotion.ts: module-state verdict registry (parked-watcher pattern) with content-equality notify damping; applied in the existing scrollback-rows effect in use-terminal-pane-lifecycle. - selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone. - Retention TTL wakeups now also cover exempt worktrees so demotion fires. - Kill switch: settings.terminalHiddenScrollbackDemotion (default on). Co-authored-by: Orca * fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix) applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling it from applyReattachPayload (already inside the coordinator when a relay replay exists) deadlocks on the coordinator's tail chain. The model paint now mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate + screen, dimension-matched, escape tail last) and arms the restored-snapshot seq baseline so deferred/live chunks the snapshot covers dedupe instead of double-painting. Also falls through (no early return) so reattachPayloadApplied still latches. Adds the folder-workspace id parity unit case. Co-authored-by: Orca * test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1) Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and reveal restores marker content at multi-viewport scrollback depth. DESIGN.md records the as-built deltas (inline paint, force-park shape, last-active floor) and the residuals so follow-ups aren't lost. Co-authored-by: Orca * fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1) A relay restart empties the replay buffer; the reveal previously painted nothing even when main's headless model held the session. The reattach now prefetches the model snapshot when no structural replay exists (SSH-shaped ptys only) and paints it inside the coordinator; emptiness is judged on the composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an alt-screen snapshot with an empty screen frame still paints. Co-authored-by: Orca * fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2) Per the approved contract each slice reverts behind its own switch: slice C now requires only the master terminalHiddenViewParking plus its own terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for demotion candidates even with the budget switch off. No DEFAULT_SETTINGS entries exist for sibling flags (defaults are the '!== false' optional pattern), so no explicit defaults are added. Co-authored-by: Orca * fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3) One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park for its whole worktree, pinning co-located remote-runtime tabs forever. The worktree now force-parks while exempt tabs keep their mounted panes via a per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync, legacy render, and the overlay cold-parking hook). Ordinary parking is untouched — a worktree with an exempt tab still cannot ordinary-park. Slice C now also demotes exempt tabs' panes as soon as their worktree force-parks under the count budget (they are the only panes left mounted). Co-authored-by: Orca * fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4) The last-active exemption means a single hidden un-parkable worktree never force-parks — and slice C previously only targeted exempt-tab worktrees, so its panes held full scrollback forever. Demotion now also covers un-parkable non-exempt worktrees past the retention TTL that are absent from the force-parked set (last-active spared, or slice B switched off). Membership stays time-monotone for fixed inputs; covered by new idempotence/monotone selector tests. Co-authored-by: Orca * fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5) Whole-worktree background mounts (browser-automation bootstrap lease, mobile mounts, agent wakes) open a ~3s self-clearing measure window that previously deleted hiddenSince — every remount restarted the 30s hysteresis and the 45min retention TTL, so a periodically re-mounted force-parked worktree never re-parked. The measure window still pauses parking/eviction verdicts (all selectors skip measuring candidates); only the clock survives, so the prior verdict resumes as soon as the window closes. Visible and portal-holding worktrees still reset the clock. Co-authored-by: Orca * test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a) Pad the session with ~180KB of output after the numbered markers so the earliest marker falls outside the relay's 100KiB rolling replay buffer while staying inside main's ~5k-row headless model; asserting marker_1 after reveal now proves the headless-model paint rather than passing under the relay fallback. Co-authored-by: Orca * docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7) One contract matching the code: status IMPLEMENTED around force-park (not the unmount proposal), real kill-switch names with coupling + revert matrices, the true retention-floor formula with measured per-pane and demotion numbers, an explicit when-OOM-is-still-possible paragraph naming the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock constraint inside the slice-A section, stable-signal phrasing instead of a capability latch, fail-open AND foreign-worktree exemption class, verified cites, and a planned/landed/follow-up test matrix. Co-authored-by: Orca * fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8) isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty — while the coverage veto that makes a worktree a retention candidate walks every pane. A split tab whose second leaf held an unrestorable pty therefore failed coverage (→ force-park target) yet looked exempt-free, so force-park unmounted it and orphaned the live shell. The exemption now resolves panes through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in the union for the no-layout/no-capture case. Also from the same review round: - force-park's capture passes includeLocalBuffers:false like every other shutdownBufferCaptures caller; it was serializing up to 512KB/pane of scrollback into the store inside a fix meant to bound renderer heap. - Terminal.tsx unmount resets the scrollback-demotion registry — module state with no reset path, read by a pane effect that runs before the host effect that would clear it, so a stale verdict trimmed restore replays. - memoize watcher coverage per tab within the parking pass; the retention candidates re-asked it for every mounted worktree, not just the parked few. * docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body Co-authored-by: Orca * fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2) pendingSideEffects grew without bound under background timer throttling (~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with oldest-first eviction: titles drop (last-wins), a pending bell latches onto the next survivor, agent-status payloads collapse onto the survivor keeping the newest 16 (last-wins store state, KB-scale strings). Co-authored-by: Orca * fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up) Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so OSC 133;D and Command Code scrape signals went dark while parked. New parked-terminal-command-status.ts ports the store-level subset: git-UI nudge on every command finish, same-turn status-row drop for SSH PTYs (exact mounted-path parity — the foreground tracker refuses SSH ids), and the Command Code working seed / 1500ms done settle. Byte mode scans the same shared parsers for authority-off parity. Local-PTY status drops stay with the mounted pane: they need pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a real agent exit. Co-authored-by: Orca * test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b) ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config → getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer only) so a spec can shrink the force-park budget to 1. The Docker-gated spec opens two remote worktrees on one relay target (second pre-seeded remote repo), disables terminalSshViewParking to make both un-parkable, hides both behind the local context, and proves the older one force-parks while the last-active exemption spares the newest; re-activating the evicted worktree restores the marker tail via relay replay. Co-authored-by: Orca * test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane) The first draft added a second remote repo mid-session, whose pane pty spawn misroutes to the local daemon with the remote cwd (pre-existing multi-repo issue, reproducible without any retention override — a seeded local repo plus one remote repo shows the same misroute). The spec now budgets across three worktrees of the ONE connected repo, created through the product createWorktree path (an external git-worktree-add only lands as a detected worktree needing adoption) and polled through the relay's transient post-connect reconnect window. Verified green on the local Docker lane in 20.8s. Co-authored-by: Orca * fix(terminal): prevent remount thrashing during post-measure cool-down ( Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a background-measure window (so TTL/ranking stay honest), but re-park waits for a full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down, every ~3s measure lease on a past-deadline worktree thrashes remount/reattach. Core changes: - Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates until cool-down expires. - Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts (used by SSH reattach + daemon restore paths). - Add SSH model snapshot timeout (750ms) with fallback to relay replay. - Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts; add cool-down deadline to scheduling. - useTerminalTabColdParking: implement matching measure-clock contract with per-tab cool-down gate to keep tab deadlines synced with worktree retention clock. - Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted worktrees (pane births during demotion must take the demoted tier at create). - Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget, terminalHiddenScrollbackDemotion. * fix(terminal): detect Command Code completion in parked mid-turn panes Seed the byte watcher with in-flight turn state from agent status: the watcher is recreated per park cycle with no startup command to arm it, and the banner scrolled away before parking. Also memoize eviction-exempt checks and use SSH PTY ID builder in tests. * fix(terminal): flush pending command-code settles on reveal remount When a parked pane reveals mid-Command Code turn, the new detector cannot re-observe the already-passed idle composer. Cancelling the settle leaves the row stranded at 'working', so dispose now flushes the pending settle instead. Extract readInFlightCommandCodeTurn to shared space and seed detectors with in-flight turns so remounts complete mid-flight commands. Also memoize SSH model probes to prevent double timeouts on reattach. * fix(terminal): remove scrollback demotion (C1 slice C) The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic. * test(terminal): assert bounded probe during stalled reveal Add assertion to verify that a stalled reveal operation makes exactly one `getMainBufferSnapshot` call, ensuring retry logic doesn't introduce redundant probes that would extend the timeout window before relay fallback. * fix(terminal): implement C1 retention budget for hidden parked worktrees Addresses OOM regressions in hidden parked terminals by force-evicting worktrees past a retention budget: at most 12 mounted while hidden, none past 45 minutes (absolute, not exempted by last-active). Eviction is least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep their panes to avoid orphaning shells; worktrees are force-parked even if they contain exempts, and their buffers released elsewhere. SSH/remote worktrees serialize buffers pre-eviction for reveal; local worktrees keep daemon snapshots. Command Code's done-settle window is transferred across park/reveal boundaries so the row cannot strand at 'working'. Model probe on SSH reattach is scoped to park-reveal only, not ordinary reconnects. Includes new E2E suite proving the budget actually releases memory. * memoize eviction-exempt terminal tabs to avoid redundant store reads Each tab's exemption check re-reads the store and walks the layout tree. Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs for a worktree in a single pass, then memoize the result in Terminal.tsx and useTerminalTabColdParking. This prevents O(n) store reads when checking exemptions across multiple tabs and ensures the set remains stable across unrelated re-renders. * refactor: reformat hidden-worktree retention comments Reflow to 80-character lines and remove internal ticket references (C1, C1 slice C). * fix(lint): split overlay slot and eviction-exempt tabs under max-lines Static analysis failed because TerminalPaneOverlayLayer (401) and terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the slot component and eviction-exempt helpers into dedicated modules. * test(terminal): stabilize retention budget e2e control arm Stage un-parkable remote pty ids only after both worktrees are hidden, and keep re-staging during the control-arm poll so a late updateTabPtyId cannot flip the decoy back to park-restorable and ordinary-park it before budget engages. * test(terminal): pin retention e2e decoy to a mounted pane snapshot Use the active pane-identity snapshot for the decoy tab instead of all worktree tabs, and re-assert un-parkable ids after the control-arm hold so a deferred/empty tab id cannot fail the budget-off mounted-count check. * fix: memoize terminal eviction exemptions on layout leaf PTYs Splits add leaf panes to the layout store without changing the tabs array. A memo keyed only on tabs misses this change, leaving new panes unexempted for unmount. Include layout leaf PTYs in the exemption memo key so it recalculates when splits occur or PTYs are re-minted. --------- Co-authored-by: Orca * fix(gitlab): refresh self-hosted provider detection (#9909) * fix(gitlab): refresh self-hosted provider detection * fix(gitlab): preserve auth refresh during host probe * fix(gitlab): merge refreshed auth hosts linearly --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(worktree-palette): stop blanked display names from crashing Cmd+J (#11323) * fix(worktree-palette): stop blanked display names from crashing Cmd+J Blanking the "Display Name" field made buildWorktreeMetaUpdates emit `displayName: undefined` as a present key. The store's `{ ...worktree, ...updates }` spread then erased the live name, so the next palette keystroke threw "Cannot read properties of undefined (reading 'toLowerCase')" in searchWorktrees (crash a1f81ea1, build 1.4.159). Fixed at three layers so no single guard is load-bearing: - Producer: persist the blanking intent as '' instead of undefined, and let WorktreeSet accept '' so remote/SSH hosts stop dropping the clear. - Store: applyWorktreeUpdates and applyDetectedWorktreeUpdates drop present-but-undefined keys for fields Worktree declares required. - Readers: resolveWorktreeDisplayName/resolveWorktreeBranchLabel mirror the main-side mergeWorktree fallback (custom -> branch -> folder) for all four Cmd+J searches, the checks/review index, and the render site. Co-authored-by: Orca * test(worktree): assert omitted display name shape --------- Co-authored-by: Orca * fix(terminal): verify clipboard writes so TUI "Copied" never lies (#10827) * fix(terminal): verify clipboard writes so TUI "Copied" never lies Windows/Electron can return from clipboard.writeText without updating the OS clipboard, so Claude Code / OpenCode OSC 52 copy and terminal selection copy looked successful while paste stayed empty (#8977, same root as #5611). Verify standard clipboard writes by reading back after write, surface OSC 52 host write failures with a toast, and route selection copy through a shared helper that only clears the selection after a confirmed write. * fix(terminal): harden clipboard write verification * fix(terminal): contain clipboard failure notifications * fix(terminal): isolate verified clipboard writes * fix(terminal): address greptile clipboard verify nits Drop the dead onWriteFailure pass-through from the coalesced OSC 52 handler so failure toasts stay owned by the microtask path. Cover multi-line / CRLF identity in write+verify tests, and export the verification-failed error constant for stable matching. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Revert terminal rendering changes from #10692, #10794, #10871, and #10907 (#11338) * Revert "fix(terminal): avoid flash while restoring parked terminals (#10871)" This reverts commit 5a6a9e0b28d72856c995d7e6400a5364e5126321. Reverted for terminal rendering regressions (flashing, lost content). Conflict resolution preserves the forwardRef signature from #10433 and drops the parked-presentation gating #11016 fed with its effective set. Co-authored-by: Orca * Revert "fix(terminal): limit pre-paint WebGL resume to macOS (#10794)" and "fix(terminal): stop switch bold flash and Windows lag (#10692)" This reverts commits 4681edb520a7bff623db6311dbdafde3818dd6ab and 8f5a45401fc65b4052797b43d251d44c9c691769. #10794 was itself a partial revert of #10692, so both are reverted together: the Windows retained-WebGL LRU and the macOS pre-paint (layout-phase) visibility transition that survived it. Terminal visibility resume returns to passive disposal and recreation on every platform, and the WebGL context ceiling returns to a flat 128. Co-authored-by: Orca * Revert "fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)" This reverts commit 97cb32c1ccd4869829f8119c81fb168b18acb57c. --------- Co-authored-by: Orca * fix(sidebar): contain nested agent metadata (#11336) * release: v1.4.162-rc.0 * fix(activity): bound Activity portal readiness oscillation (React #185) (#11326) * fix(activity): bound Activity portal readiness oscillation (React #185) React error #185 is "Maximum update depth exceeded" -- an unbounded render loop, not setState-after-unmount as the crash reports superficially suggest. Verified in react-dom@19.2.7's production bundle: getRootForUpdatedFiber throws Error(formatProdErrorMessage(185)) when 50 < nestedUpdateCount. That frame is the top of all 14 crash stacks across shipped 1.4.156 / 1.4.158 / 1.4.159. nestedUpdateCount and rootWithNestedUpdates are module-level globals keyed on the ROOT, not on a component. One runaway loop saturates the counter and the throw lands on whichever fiber calls setState next, so SortableTab (terminal.workbench), the Radix Presence/MenuPortal (page.settings) and the sidebar.worktrees button are innocent bystanders with misleading component stacks -- four boundaries, one bug. The driver is Activity's portal readiness pass. When 'ready' is unreachable while 'unavailable' stays reachable, updateReadiness flips loading<->unavailable from inside useLayoutEffect -- React's sync lane, the lane that increments nestedUpdateCount -- so it saturates and throws rather than merely being slow. A prior investigation captured this live on Windows via CDP at 239 renders in 847ms. Bound the oscillation: after ACTIVITY_PORTAL_READINESS_MAX_FLIPS consecutive loading<->unavailable transitions the latch parks on 'unavailable' until a real 'ready' arrives. The latch rewrites only the hook's output, never the DOM probe, so a terminal that churns during a slow attach and then genuinely comes up still releases it. Because the counter is global per root, this also protects every innocent bystander in the app -- most of the user-visible value. Extract reconcileActivityPortalThreads and resolveActivityPortalSwap so the portal loop is testable against the real collaborators. Note the reconciliation keeps its worktree+tab comparison deliberately: Terminal mounts one TerminalPane per (worktree, tab) and routes it by worktree+tab, so staging a same-tab pane would leave the staged slot empty and its readiness stuck on 'loading' forever. Same-tab switches swap in place via isolatedPaneKey instead. Co-authored-by: Orca * fix(activity): coalesce portal readiness updates * test(activity): tighten readiness regression coverage --------- Co-authored-by: Orca * fix(orchestration): preserve active workers across updates (#11271) * fix(orchestration): preserve active workers across updates * test(ssh): model absent legacy adoption * test(orchestration): align compatibility contracts * fix(windows): escape updater PowerShell booleans * fix(windows): restore stock uninstall process check * fix(orchestration): keep recovery off renderer startup barrier * fix(orchestration): harden legacy recovery migration * fix(orchestration): close recovery review gaps * fix(orchestration): complete legacy worker cutover recovery * fix(orchestration): preserve legacy workers across updates --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): route external mouse/trackpad wheel through the terminal scroll router (#11247) The mobile terminal WebView only handled touch. Wheel events fell through to xterm, which either scrolls its own hidden viewport or — in the alternate screen — emits cursor keys via onData, and the mobile onData bridge forwards those to sendMobileTerminalQueryReply, which drops anything that is not a query-reply grammar. Net effect: an external mouse or trackpad scrolls nothing inside the terminal, and nothing reaches the PTY. Attach a wheel handler on the terminal surface that reuses the touch path's router: alternate-screen and mouse-aware TUIs get bounded cursor keys / wheel reports through the existing validated terminal-input gate, and the normal buffer gets the same coalesced scrollback scroll as a swipe. Refs #6863, #8818 * feat(browser): let Shift invert link routing instead of always forcing the system browser (#10991) * feat(browser): let Shift invert link routing instead of always forcing the system browser Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a no-op when that is already where links go. Users who keep Link Routing off have had no gesture to pull a single link into Orca's built-in browser. Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes the modifier open a link the opposite way from the setting. It ships off, so the one-way escape hatch is unchanged for every existing user. The title and description name the destination the modifier actually reaches and flip with the parent setting, since "the opposite" is meaningless on its own. - openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes identically. forceSystemBrowser stays for callers that must bypass settings. - The terminal hover hint names Orca when the modifier would open there, and is re-resolved per hover so toggling applies without recreating panes. - Link Routing's own copy drops "always uses your system browser", which the new toggle can falsify; the nested row states the live destination instead. * fix(browser): route the Checks panel hosted-review link through the shared modifier The "Open on GitHub" button had its own Shift+Cmd/Ctrl escape hatch that passed forceSystemBrowser directly, so it kept the old one-way behavior while every other surface honored the invert setting. Route it through modifierHeld like the terminal and markdown paths. Also wraps the modifier row's description in translate(); the title in the same file was localized but the description returned raw English (caught in review). * fix(browser): make link-routing modifier copy true in every state Review follow-ups on the Shift-inverts-routing change. - The nested row promised "⇧⌘+click opens one in Orca" in the present tense while its own toggle was off, so the out-of-box state described behavior the user did not have. Phrase it as enabled-state copy, matching sibling rows. - The parent Link Routing description gained "opens a link the other way", which is false in the default state and contradicts the child row when inverting is on. No fixed sentence there is true in every state, so the child row — which knows the live destination — now owns the claim. That leaves getBrowserLinkRoutingShortcutLabel unused, so drop it. - The rich markdown editor still forced the system browser while the preview of the same file honored the modifier, so one link routed two ways depending on which view it was clicked in. - A remote runtime pins every link to the system browser, so the hover hint could promise Orca for a click that lands elsewhere. Gate the hint on the same condition openHttpLink uses. - Index both modifier titles: the search entry is built with openLinksInApp false, so the row was unfindable by the title it renders when routing is on. - Drop the ariaLabel that shared no words with the visible label (WCAG 2.5.3) and add the four missing settings-search keyword keys to all five catalogs. * test(editor): pin the rich markdown Shift+click routing hop The editor half of the modifier fix had no coverage — reverting it to forceSystemBrowser left the suite green while the same link routed one way in the markdown preview and the other way in the rich editor. Also pins that a non-local source owner survives the hop, since that is what keeps an SSH file's links out of Orca's browser. * test(editor): cover the Ctrl chord for rich markdown link routing This file is the only test of handleRichMarkdownEditorClick, and it exercised metaKey alone, so the isMac branch of modKey had no coverage off macOS. Also stop claiming the source-owner case proves SSH links stay out of Orca — it proves the owner survives the hop; http-link-routing.test.ts enforces the rest. * style: trim review comments to the one-line house rule Both explained the change adequately in two lines; the extra lines were worked examples, not information. * fix(browser): keep Link Routing copy unchanged until inverting is enabled Removing the "⇧⌘-click always uses your system browser" sentence outright reworded the row for every user on upgrade, including everyone who never turns the opt-in on. Restore it verbatim in the default state and only hand the chord sentence to the nested row once inverting makes "always" untrue. * revert(editor): keep rich markdown Shift+click on the system browser Per Brennan: the editor's Shift path hands the link to the client OS and should not follow the invert setting — the preview opening in Orca is the intended divergence, not a bug. Restores main's call exactly; the test now pins the divergence so a future consistency pass cannot erase it silently. * fix(browser): surface the inverted modifier on the hosted-review link The hosted-review click path now passes modifierHeld, so with inverting on and Link Routing off the chord opens in Orca — but the hint stayed gated on openLinksInApp, hiding a live gesture. Resolve the destination instead of a boolean. Default-off output is unchanged. * test(browser): pin the inert modifier hint when links already open in Orca * refactor(terminal): require the pane link hint so dropping the wiring fails to build The optional option fell back to a duplicated copy of the legacy hint string, so deleting the hook wiring reverted the tooltip silently with every test green. * fix(browser): trim the runtime id before hiding the hosted-review modifier hint openHttpLink and terminalUrlOpenHintOptionsFor both trim, so a blank runtime id hid the hint while the click still reached Orca. * fix(mobile): stop a mobile reveal from minting a duplicate terminal tab (#11259) Revealing a live PTY from a paired phone passes the tab id baked into its env, but the create-terminal handler only recognised ownership via tab.ptyId and the live ptyIdsByTabId map. With the worktree closed on the desktop no pane is mounted, so ptyIdsByTabId is empty and tab.ptyId holds at most one leaf's PTY -- a split pane's second PTY lives only in the persisted layout. Ownership missed, and createTab ran with an id that already existed; it mints a fresh uuid on collision, so a second tab appeared on the same session (#10486). Resolve ownership through the persisted layout as well. Every binding consulted is an exact match on the ptyId, so all of them outrank the reveal's tab id hint: that hint is written once when the PTY spawns and is never rewritten, so it goes stale as soon as a pane is dragged to another tab. A mounted pane outranks a recorded one, same-tier conflicts stay ambiguous so the mobile mount planner keeps failing closed, and the hint is what resolves a reveal that nothing records yet -- which is also what keeps paneKey hook attribution intact. * fix(mobile): keep the provider session id alive while an agent sits idle (#11260) Mobile Chat UI subscribes to an agent transcript by providerSession.id, so losing that id blanks the chat: use-mobile-native-chat-session clears the message list and then returns without subscribing when sessionId is null. Two places dropped the id on a status ping that carried no session metadata, both while the agent was idle at its prompt — exactly when mobile reads it: - The renderer store refused to carry the id across `done`. A completed turn does not end the provider session (the TUI stays alive and resumable), and OSC 9999 repaints plus reconnect snapshot replays re-deliver a metadata-less `done` onto an already-done row, so retention has to cover done -> done. - The main-process OSC ingest overwrote the cached row without the id. The OSC wire payload has no providerSession field, so an OSC observation is never evidence the session ended. Dropping it there erased the id from persisted rows (lost across restart) and from headless `orca serve`, which serves those rows to mobile directly rather than through the renderer store. Both keep the turn boundary: a new turn after `done` still starts clean, so a reused pane cannot inherit a finished session. Closes #10630 * fix(windows): separate updater from orchestration migration (#11405) * fix(windows): separate updater from orchestration migration * fix(terminal): attest adopted reveal identity --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * perf(main): move hang watchdog into a worker thread (#11344) Keep main-thread hang detection independent of the blocked Electron event loop while reducing watchdog memory from 47.1 MiB to 11.5 MiB. Preserve marker, recovery, and telemetry behavior with a bundled worker-thread entry. * Add OMP skill discovery source (#6422) * Add OMP skill discovery source * review: scope OMP discovery to the shared provider and cover orchestration - Drop the `SkillProvider` widening: `~/.omp/agent/skills` is a provider home like `~/.pi/agent/skills`, so it carries `agent-skills` and identifies OMP through the source `owner`. That keeps every `Record` (including the Skills page label map) at a zero diff. - Add the `omp-home` orchestration coverage location and map OMP to it, so the Agent coverage panel stops reporting a real OMP install as missing. * review: guard the Pi/OMP matcher anchors Both roots end in agent/skills, so dropping either leading segment let one agent's install mark the other with a green suite. Also corrects the OMP owner assertion's rationale: no OMP native-chat profile exists, so owner matters because a null owner leaks OMP-only skills into other pickers. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(codex): restore five-hour usage window (#11415) * fix(codex): restore five-hour usage window * fix(codex): reuse backend reset credit metadata * fix(settings): reserve skill badges for attention (#11413) * fix(settings): reserve skill badges for attention * test(settings): cover hidden checking badge * test(skills): execute the plugin-cache entry limit instead of reasoning about it (#11255) * test(skills): execute the plugin-cache entry limit instead of reasoning about it The entry bound that ended a plugin-cache scan was never reached by any test. Every existing `entry-limit` case builds a synthetic issue object at the display layer, so both emission sites in scanKnownPluginSkillCandidates — the dirent read loop and the declared-skill-root resolve — were verified by reading the code. The real bound is 16,384 dirents, and the declared-root guard only fires when the count lands in a one-entry window below it, which is why neither was ever fixtured. Makes the entry bound injectable the way the candidate bound already was, folding both into a `PluginSkillScanBounds` bag so a fourth positional number is not needed, and adds: - a case that truncates the dirent read and asserts the scan drops both real skills and reports `entry-limit` at the root; - a case that truncates while resolving declared roots, asserting the declared root that exists was still walked so the guard under test is the resolve one; - an inventorySkillFreshness case at the production 16,384 bound asserting a truncated scan produces zero fabricated placements — the #10918 regression. No bound value or scan behavior changes. Refs #10918. * docs(skills): state the declared-root guard's real window in the bounds comment * test(skills): pin entry-limit to the scan root, not the crossing directory Both entry-budget fixtures crossed the bound in the same directory they named, so swapping recordIssue(rootPath) for recordIssue(directory) at either guard passed the whole suite — the dialog would surface a nested path and no test would say so. Cross the budget below the root instead. * test(skills): assert the declared-root guard's threshold, not just its firing The declared-root entry guard's ±1 boundary was left unasserted on the claim that it is unkillable: admitting one more declared root was said to always cost a dirent the read-loop guard then catches identically. It does not. A declared root that does not exist reads no dirent, and when it is the last one the loop simply ends — so the scan completes and reports nothing. Restates both budgets against the fixture's exact entry count: one short of it, where only the last root's resolve can cross, and exactly at it, where nothing should be reported. `>` -> `>=` and `>` -> `> max + 1` now each fail a test. * test(skills): assert the entry bound stops the walk, not just what it reports Dropping `limitReached = true` at the dirent guard survived every case: the already-read entries of the crossing directory are still descended, and the scan reports a depth-limit for a path it never reached. A nine-level chain whose deepest directory sits at the depth bound and crosses the budget on the second of its two children kills that, with no symlink and no Windows skip. * test(skills): derive the walk-stop fixture from the depth bound it depends on The walk-stop case detects a dropped `limitReached` only because its chain is exactly MAXIMUM_PLUGIN_SCAN_DEPTH deep, so the entries already read when the entry bound trips are rejected on depth if the walk keeps going. That coupling was a hardcoded 9. Raising the depth bound left the test passing while it stopped killing the mutant it is the only cover for — verified by A/B: at depth 12 the hardcoded fixture lets the mutant survive, the derived one does not. Other tests in the file fail loudly on that change, which is exactly what makes the silent one dangerous. Exports the bound the way the file already exports its four siblings for the same reason. No behavior change. * fix(skills): preserve symlinked agent coverage (#8329) Settings → Orchestration → Agent coverage decided whether each detected agent had the orchestration skill by matching a skill's single `rootPath` against a hand-maintained table of path segments. Skill discovery dedups by canonical file path, so when one provider home is a symlink onto another agent's skills directory — which `npx skills add --global` does — the two collapse into one row and the absorbed roots survive only in `rootPaths`, which the old matcher ignored. With `~/.grok/skills` symlinked onto `~/.claude/skills`, Claude read Ready and Grok read Missing even though Grok loads the same files. Now classifies every entry in `rootPaths` and resolves each root through the `SkillDiscoverySource` the scanner already returns, accepting it when `sourceKind` is not `repo` and `owner` is either the agent's owner or `null`. Deletes `ORCHESTRATION_SKILL_LOCATIONS` and `ORCHESTRATION_SKILL_LOCATION_IDS_BY_AGENT` — a renderer-side mirror of `buildSkillDiscoverySources` that had to be hand-edited for every new provider root, which is what caused this class of bug. No discovery roots change here; those landed in #8510. Fixed during review: - Restored the required `{ kind: 'local' }` argument to `useDetectedAgents`. Dropping it failed typecheck (TS2554) and pinned `detectedIds` to null, leaving the widget on "Checking installed agents and skill paths…" forever. It also reverted the remote-host scoping from #9790. - Stopped a duplicate repo root from shadowing an owning home root. `SkillDiscoverySource.path` is not unique: when the scan cwd is the home directory, `~/.claude/skills` is scanned as both a home root and a repo root, and a path-keyed Map is last-write-wins — so every agent read Missing while the panel above said Installed. Guaranteed in Settings on a WSL project. - Carried OMP coverage in the owner-based shape so #6422's coverage-table hunks could be dropped on merge. Behavior note: an orchestration skill inside an enabled Claude plugin now counts for Claude. That is a deliberate consequence of trusting the scanner's ownership data, matches how the Codex plugin cache was already treated, and is pinned by a test. Refs #8256 Co-authored-by: sdhilip200 <49802211+sdhilip200@users.noreply.github.com> * fix(terminal): reconcile cross-platform IME composition lifecycle (#11293) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: JeongUk Park * fix(skills): read installed skills from the connected remote runtime (#6887) * fix(skills): read installed skills from the connected remote runtime The "Not installed" badge stayed on in Settings even after a skill was installed against a remote `orca serve`. Skill discovery always ran through the local `skills:discover` IPC, so it scanned the client's home dir while the install (and the skill files) landed on the server. The skills browser had the same blind spot. Route discovery to the runtime that owns it, mirroring git/hooks/terminal: local IPC by default, remote runtime RPC (`skills.discover`) when an Orca runtime environment is active. The discovery cache is now scoped per runtime so local and remote results never collide. Extract the discovery cache/transport into a store-free module (`installed-agent-skill-discovery.ts`) and a shared runtime-target hook, so the React hook stays under `max-lines` and store slices can import the change-notifier without pulling the app store into a circular import. Independent of the terminal selector fix (#6816); addresses the still-broken install-status half of #6789. * fix(skills): runtime-agnostic scan-error toast + docstrings Address review on #6887: - The skills-scan error toast said "Could not scan local skills", but discovery can now target a remote runtime; drop "local" (source + locales). - Add short JSDoc to the discovery helpers and skill hooks to clear the docstring-coverage gate. * fix(skills): route discovery through the active runtime and scope its cache Rebuilt on the repo's standard runtime-client pattern (getActiveRuntimeTarget + callRuntimeRpc) instead of a bespoke transport, and keeps the renderer discovery cache keyed per runtime so a remote result never leaks into the local host's badge after switching environments. * fix(skills): keep the discovery cache store-free to break the import cycle Reading the active runtime from the app store inside the hook module closed a cycle (store -> repos slice -> hook -> store) that broke module init in three suites. The cache/transport moves to a store-free module the repos slice can import; only the hook itself touches the store. * fix(skills): scan the host the install actually lands on Reviewer round 1 found the badge could scan a different machine than the Install button writes to: skill install terminals route through getSingleFocusedRuntimeEnvironmentId, which declines to guess an owner while several runtimes are saved, so a two-environment user installed locally while discovery scanned the remote and the badge never flipped. Discovery now resolves through that same resolver, and holds a loading state until settings and the runtime catalog have hydrated instead of flashing 'Not installed'. Also drops the unreachable cwd/worktreeId forwarding (no caller can produce it) and retires three SkillsPage strings that a remote scan makes false. * fix(skills): close the round-2 review findings - SkillsPage had no generation guard, so a slow local scan could land after a newer remote scan and silently redisplay the client's skills. - The round-2 selector took a useShallow object including runtimeEnvironments, whose identity churns on every status refresh; that re-fired every consumer's scan. Select the resolved id instead. - A failed runtime-environment catalog read never set the hydrated flag, so discovery would have spun for the whole session with no retry affordance. An unreadable catalog is settled, which is what terminal routing assumes. - Remote cache keys no longer fragment on a client-side target the remote call discards, which was issuing the same RPC once per target shape. - Cover each hydration conjunct separately; the combined test covered neither. - First tests for SkillsPage, which had none. * fix(skills): settle the runtime catalog without loosening host routing Round 3 set runtimeEnvironmentCatalogHydrated on a failed catalog read so skill discovery would stop waiting. That flag also gates fail-closed host routing (worktree-operation-route mayBeLegacyLocal), so flipping it on failure would have routed ownerless legacy worktrees — including removals — to the local host off a stale empty list. Add a separate 'settled' flag for surfaces that only need to stop waiting, and leave 'hydrated' meaning what its doc says. fetchSettings now probes the catalog even when the settings read fails, so a rejected settings.get cannot strand every skill badge on a spinner. * test(skills): pin the settings-failure runtime catalog probe The only hunk in the review no mutation could kill. --------- Co-authored-by: vladmesh Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(computer): reap mac helper after client loss (#11425) * fix(settings): guide Windows skill setup when npx is missing (#10453) * fix(settings): guide Windows skill setup when npx is missing * fix(settings): resolve npx by PATHEXT and keep the preflight off POSIX shells Review fixes on the Windows npx preflight: - Probe and run bare `npx` instead of pinning `npx.cmd`. cmd.exe resolves both through PATHEXT, so shims that ship `npx.exe` (Volta) no longer get told "npx was not found" on a machine where npx works. - Force the skill terminal to PowerShell when the configured Windows shell is POSIX-family. Git Bash rewrites the leading `/d /s /c` arguments as MSYS paths, which would break a command that runs fine there today. - Drop the "restart Orca" advice. Every new PTY merges the persisted Windows PATH, so a new setup terminal already picks up a fresh Node install. - Fall back to the plain command on the two newly routed call sites when the project runtime is repair-required, matching the eight existing callers. Those sites otherwise emitted a wsl.exe command for a missing distro. Adds coverage for the Git Bash override and for cmd.exe block safety. * fix(settings): apply the skill-terminal shell override on every wrapped path The Windows npx wrapper is emitted whenever buildSkillCommandForRuntime falls back to the local host, but three paths never consulted the shell override, so a Git Bash user still got the cmd.exe string pasted into MSYS: - useActiveProjectSkillRuntime returned an empty result whenever no local project runtime resolved (no repo yet, or an SSH/remote repo) while the command builder kept wrapping. It now resolves the override for that same host fallback, so the wrap gate and the shell gate agree. - The Linear setup prompt carried a third copy of the override that was still on the old wsl.exe-only check. It now delegates, as does the settings copy, leaving one implementation. - MobileEmulatorAgentControlRow built wrapped commands but passed no override. Also drops the unreachable WSL guard inside the wrapper; the only caller is already on the non-WSL branch. * fix(settings): keep the npx preflight off remote runtimes and finish the emulator row - Skill setup terminals spawn on the focused runtime environment, so a Windows client was handing a cmd.exe command to a remote Linux host where the plain npx command used to run. Skip the wrapper whenever a runtime environment is focused. - MobileEmulatorAgentControlRow was left half routed: it took the project runtime's shell override while still building a Windows host command, so a WSL project got the cmd.exe wrapper inside a WSL shell. Build its commands from the same runtime, matching MobileEmulatorAgentSetupGuideSteps. * fix(settings): match the terminal router when skipping the npx preflight - The remote check used settings.activeRuntimeEnvironmentId, but the setup terminal routes through getSingleFocusedRuntimeEnvironmentId, which keeps the terminal local unless exactly one saved environment is focused. Users with two or more environments lost the preflight while still running locally. - LinearAgentSkillPane memoized its commands on the project runtime alone. The built command now also depends on the focused runtime environment, and Settings panes stay mounted, so the memo could serve a stale Windows command after a runtime switch. Compute it inline like every sibling pane. * fix(settings): keep emulator skill setup installing where detection looks The mobile emulator surfaces detect the Orca CLI skill with a local-host scan that takes no discovery target, so building their commands from the project runtime made a WSL project install into the distro while detection kept scanning Windows: the panel stayed "Not installed" with no way out. Build host commands there again, as the surrounding comment already documented, and keep only the terminal shell override those surfaces actually needed. Also narrow the remote check back to the focused environment id. Matching the terminal router exactly meant reading runtimeEnvironments, which nothing on these surfaces subscribes to, so adding or removing an environment mid-session could leave a stale decision. The focused id alone over-skips instead, which degrades to the previous behavior rather than sending cmd.exe to a remote host. * fix(feature-tips): keep the npx preflight on the repair-required fallback installDisabledReason is only ever set on Windows, so dropping the whole command builder on that branch stripped the npx preflight exactly where it is needed. This terminal auto-pastes with no install gate, so that fallback put the bare npx command straight back in front of the user #10438 describes. Drop to the host runtime instead, which still avoids the missing WSL distro. Pins the two call-site invariants that were unguarded: the emulator surfaces must build host commands because their detection scans the host only, and the Linear prompt's shell override must cover POSIX-family Windows shells. * fix(settings): reach the npx preflight from the ephemeral VMs pane too This pane was the only one of eight requiring a resolved project runtime before building its command, so a Windows user with no repo added yet — the state issue #10438 was reported from — got the bare npx command and the same dead end. An absent runtime already resolves to the local host, which is what the sibling panes rely on. Also pins the repair-required host fallback added in the previous commit; the existing assertions all passed against the old form. * test(settings): pin the ephemeral VMs pane to the host-resolving command Reverting the previous commit left the whole suite green, and this is a PR where several regressions came from an earlier fix, so guard it the same way the emulator call sites are guarded. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(skills): stop rescanning on unrelated store writes and bound the discovery cache (#7670) Two fixes to installed-skill discovery in the renderer. **Stops one discovery IPC per unrelated store write.** `refresh`'s `useCallback` depended on the `discoveryTarget` *object*, which callers rebuild inside a store-backed `useMemo`. Any unrelated store write handed the hook a fresh identity, recreated `refresh`, and re-fired `useEffect(() => void refresh(false), [refresh])`. On a cache hit that is only extra renders — but on a *rejecting* scan nothing is cached and the pending entry is cleared, so it becomes one discovery IPC per store write, indefinitely. That is the remote-runtime-unreachable and SSH case. Measured 6 scans where 1 was correct. This dep also exists on `main`, so this repairs a pre-existing bug rather than only one introduced here. Fixed with a render-phase `useState` latch keyed on the already-present `discoveryTargetKey` string — deliberately not a `useMemo` (React documents those as discardable, so a discarded one silently restores the regression while the test stays green) and not a ref (react-doctor correctly flags a render-phase ref mutation). **Bounds the discovery cache.** The module-level maps were unbounded, cleared only wholesale by `notifyInstalledAgentSkillsChanged`. Now a 256-entry LRU keyed by `getRuntimeScopedSkillDiscoveryKey`, with a `discoveryGeneration` guard and a read/peek split so only the unforced cache-serving path promotes recency. This matters more after #6887 than before it. Local keys are bounded by project count (~47 KB/entry, ~240 KB for 5 repos), but once remote scans key on `runtime:`, ephemeral VMs mint `orca-${randomUUID()}` per start (`ephemeral-vm-runtime-service.ts` + `ephemeral-vm-recipe-runner.ts`), so every VM start creates a permanently-retained entry — ~18.8 KB each, ~1.8 MB after ~100 starts, and it does not stop. Scoped down during review: an `executionHostId` change touching 8 production files was removed as a second feature (host-awareness landed in #6887 instead), taking this from 17 files to 6. New tests pin that the cap keys off the runtime-scoped key, that two environments never share an entry, and that one environment collapses to a single entry across differing client target shapes. Known gap, not addressed here: cache entries are not evicted when a runtime environment is removed, so a removed environment's skill list is retained until LRU pressure or an install notification clears it. That needs a store subscription to `runtimeEnvironments` removals and is filed separately. Co-authored-by: nwparker <4138956+nwparker@users.noreply.github.com> * fix(hibernation): reap restored subagent rows with no live agent process (#11219) * fix(hibernation): reap restored subagent rows with no live agent process A pane whose Claude session had a subagent in flight can be locked out of agent hibernation for good. A PTY that dies while Orca is down never runs the teardown that clears pane state, so hydrate rebuilds a subagent roster that nothing can retire: the existing reap needs the parent to emit a complete `background_tasks` inventory, and a parent that went idle before the restart never emits one. The restored row keeps gating the pane 'working', and hibernation only accepts 'done'. Observed locally: six panes parked at SubagentStop in state 'working' for 17 to 145 hours, each still holding a working child row. Adds a second reap path. Hydrate seeds are marked `restoredFromSnapshot`, cleared by any live lifecycle event or an id-exact running inventory entry. One post-restore sweep drops the rows still unconfirmed when the pane's PTY is absent from the live local inventory, then re-derives the child-gated 'working' to 'done'. The scan is local-only by construction: panes with a relay connection id are skipped and SSH-scoped PTY ids resolve as live, since a remote agent runs on the far host and could never appear in a local listing. An unreadable inventory is not evidence that anything exited, so it is a no-op. Panes that have reported to this runtime are left alone. `stateStartedAt` and `stateHistory` are untouched, so a draft typed while the pane was working still blocks hibernation. * fix(hibernation): prove local ownership before restored reap * fix(hibernation): require authoritative restored PTY absence * fix(hibernation): probe restored PTY liveness authoritatively * fix(hibernation): restart idle window after restored reap * fix(hibernation): type restored reconciliation timing * fix(hibernation): respect worktree host ownership * fix(hibernation): preserve same-id restored PTY rebinds * fix(hibernation): fence batched restored PTY probes * fix(computer): close helper session review gaps (#11428) * fix(computer): close helper session review gaps * perf(computer): stop released session registration retries * fix(macos): acknowledge TCC notice after close (#11412) * fix(macos): acknowledge TCC notice on close * fix(macos): require fresh TCC detection * docs: update mobile APK link to 0.0.36 (#11438) * feat(ssh): bound relay PTY output end to end (#11005) * docs: design SSH relay PTY backpressure * fix(ssh): bound relay frame decoding * fix(relay): bound PTY output publication * fix(ssh): bound PTY model admission * fix(ssh): settle closed model admissions * feat(ssh): negotiate bounded PTY consumer sessions * fix(ssh): fence exit on renderer settlement * feat(ssh): track PTY source credit end to end * fix(ssh): recover bounded PTY output across reconnect * feat(ssh): complete relay PTY output backpressure * fix(ssh): close final PTY source credit races * docs(ssh): record final backpressure validation * feat(ssh): complete relay PTY source-credit lifecycle * test(ssh): complete provider notification fixture * fix(ssh): preserve terminal source credit across rotation * fix(ssh): fail closed on recovery cancellation * fix(ssh): prioritize mux control writes after drain * fix(ssh): retire canceled relay restore deliveries * fix(ssh): order exit cancellation cleanup * fix(ssh): gate provisional source activation * test(ssh): register mux drain-priority coverage * fix(ssh): type stale owner recovery mismatches * fix(ssh): close projection replacement races * fix(relay): contain streaming edge failures * fix(ssh): secure relay endpoint credentials * docs(ssh): reconcile final backpressure lifecycle * fix(ssh): bound main IPC output lifecycle * fix(ssh): close recovery ownership gaps * docs(ssh): record exact artifact validation * fix(ssh): reject reclaimed snapshot replacements * fix(ssh): fence model admission across reconnect * fix(ssh): contain migration failure per PTY * docs(ssh): record final exact-head validation * test(ssh): align deploy fixtures with credential publication * feat(ssh): add per-target bounded output setting * fix(ssh): close source recovery review gaps * fix(ssh): latch source credit environment override * feat(ssh): make PTY source credit the default * docs(ssh): record always-on relay validation * docs(ssh): bind validation to current main * test(ssh): grant source credit in IPC fixture * test(ssh): grant source credit in fake relay --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(sidebar): move project header grab cursor to title surface only (#11435) * fix(sidebar): move project header grab cursor to title surface only Prevent grab cursor appearing over action buttons (…, +, chevron) which should show cursor-pointer, not the reorder hand. - Grab cursor scoped to icon + label surface only - Row retains data-repo-header-drag-handle for indent/padding drag targets - Actions excluded via [data-repo-header-actions] selector - Add lockstep test to keep action selectors synchronized * fix(sidebar): share project header action selector across drag contracts Address Greptile feedback: drop the format-sensitive regex lockstep parse and import one shared REPO_HEADER_ACTION_SELECTOR for repo and group headers. * fix(settings): reject malformed navigation targets (#11433) * fix(settings): reject malformed navigation targets * fix(settings): allow setup guide navigation * fix(new-workspace): stop UI flashing when typing ahead of search (#11436) * fix(new-workspace): stop UI flashing when typing ahead of search Hold branch results while queries settle, show the spinner only on initial load, use stable cmdk values, and guard selections against stale rows. This prevents the highlight from jumping around when typing faster than the debounced search settles. * fix(new-workspace): keep dropdown visible while typing within settled qu Hold the last search results while the user extends or trims the query, only hiding them when the query diverges completely. This prevents the dropdown from flashing empty between debounced keystrokes and removes the guard that made provider rows unselectable during typing. * fix(new-workspace): align held provider results with live typing Cap prefix hold by length delta, hide GitHub/GitLab/Linear rows when the field is cleared ahead of debounce, and re-sync the cmdk arm when search settles so the highlight cannot lag the resolved selection. * Fix Node 26 test gate and happy-dom storage (#11434) * ci: test PR shards on Node 26 * test: isolate happy-dom storage from Node globals * fix(dashboard): remove per-worktree status dot from agent cards (#11437) * Decouple feature copy from locale parity (#8512) * Decouple feature copy from locale parity * Fix undeclared dynamic localization key check * Fix localization code owner * fix(cli): keep automated worktree creation in background (#11445) * Set selected create-worktree agent as default (#11443) * feat(new-workspace): set selected agent as default * fix(agent-picker): guard empty default action * feat(mobile): add session.tabs.list handler to mock server (#9293) * feat(mobile): add session.tabs.list handler to mock server The mock WebSocket server had no handler for session.tabs.list, so the session screen of a paired dev client hung on 'Loading tabs' forever — the terminal pane, live input, and command input could never be exercised against the mock. Respond with a single ready terminal tab wired to the existing term-1 fixture so the whole session surface works offline. * fix(mobile): complete the session.tabs.list mock contract The new mock response omitted four non-optional fields of RuntimeMobileSessionTabsResult: publicationEpoch and activeGroupId on the result, and parentTabId and leafId on the terminal tab. Nothing caught it — the object literal had no type annotation, and MobileSessionTabsStreamHealth is generic over both result and tab. A shape-incomplete mock yields untrustworthy repros for exactly the bugs it gets used for (session tabs, split panes, pane-to-tab attribution). Fill the fields with host-realistic values: a per-process publisher epoch, a layout UUID leaf id, and the `${parentTabId}::${leafId}` surface id mobileTerminalSurfaceId actually emits. Pin the shape with an explicit return type so a future required field fails typecheck instead of silently drifting. Move the fixture into its own module: inlining it pushed mock-server-rpc-handlers.ts to 317 lines against a 300-line max-lines cap, which broke `pnpm lint` on the parent commit. It registers through the file's existing delegation chain, after the native-chat scenario so MOCK_NATIVE_CHAT=1 keeps ownership of the method. Co-authored-by: Hanjoon Choe * test(mobile): pin session tabs mock fidelity Normalize the selector-backed worktree ID like the real runtime and cover the complete terminal surface response so future contract drift fails the mobile suite. * fix(mobile): share terminal.list worktree resolution with session tabs Main added `terminalListWorktreeId`, which the rebased session-tabs fixture duplicated with a different no-selector fallback — `terminal.list` resolved to the active fake worktree while `session.tabs.list` returned a literal 'mock', so a session repro saw two different worktree ids for one screen. * test(mobile): cover the bare session-tabs worktree selector Answers the review note that only the `id:`-prefixed path was exercised. * fix(mobile): make the mock publication epoch unique per process Date.now() can repeat across a sub-millisecond restart, so the epoch did not actually guarantee the fresh-publisher identity its comment claimed. --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(computer): supervise macOS helper from main (#11441) Move native macOS helper process ownership into Electron main while preserving the sidecar as the authenticated socket peer. Add fixed lifecycle IPC, bounded claim and release handling, confirmed-exit tracking, sidecar and helper force-kill escalation, cleanup across failure paths, and focused lifecycle coverage. * Add search to kanban view (#11244) * feat: add search to workspace kanban board Search filters workspace cards by display name, branch, repo, and comment. Lanes show match counts (e.g., "2 / 5") when filtered and reset to full counts when cleared. Drag-drop indices are mapped from rendered cards to the full lane so manual-order math is correct even when hidden. Query clears when the board closes to prevent stale filters on reopen. Includes keyboard shortcuts (Escape to clear), live region announcements for matches, and i18n support. * feat: add search to workspace kanban board Adds a search field to filter the kanban board by workspace name. Range selections now index rendered cards only, preventing silent selection of hidden items when filtering. Selection badges count only the visible cards that drag/context-menu actions will move. Lane totals distinguish between empty-by-definition and filtered-away cards. Drop operations commit against the full lane while displaying filtered indices. Whitespace-only queries don't show match counts, since they don't narrow the board. * fix(kanban-search): let the board search field own Escape The board's Escape handler is a capture-phase listener on document, so it runs before React's handlers and the search field's stopPropagation could never reach it — pressing Escape to clear a query dismissed the whole board instead, and the reopen reset then discarded the query too. useWorkspaceBoardPanel now defers Escape to editable targets inside the board sheet, and the field handles both outcomes itself: clear when it has text, close the board when it does not. Also: keep focus in the field when the clear button unmounts itself, reserve counter width from the rendered text so three-digit counts cannot overlap typed text, and align the icon centering, X size, and placeholder with the sibling search fields. Co-authored-by: Orca * perf(kanban-search): defer the filter and stabilize its derived identities Clearing a query re-mounts every hidden card, so it costs roughly what opening the board costs. The input stays controlled and undebounced, but the filter now reads a deferred query so React can interrupt that work and the caret stays responsive. The match set also keeps its identity when the matched ids are unchanged. Board worktree identities churn on agent-status ticks, and a fresh Set on every tick cascaded new identities through the lane views, the rendered selection, and every memoized card. Also harden the lane full-id channel: the identity guard in resolveFullLaneDropIndex compares membership rather than length, so a stale lane of equal size no longer skips translation; serialization declines ids containing the newline delimiter instead of inventing phantom lane members; the sidebar drop path scans lane cards once instead of twice; and the unfiltered full-id fallback is no longer offsetParent-filtered, restoring the pre-branch notion of lane membership. Adds coverage for the stale-equal-length lane, the full-id round trip, regex metacharacters and non-ASCII queries, and the over-bound query at the drawer level. Co-authored-by: Orca * fix(kanban-search): leave mid-composition Escape to the IME Escape during an IME composition cancels the in-progress reading. The search field was clearing the query behind it instead, matching the isComposing guard other keyboard handlers in the app already use. Co-authored-by: Orca * fix(kanban-search): stop a hidden anchor from collapsing a shift-click A query can hide the selection anchor while leaving the rest of the selection on screen. updateWorktreeSelection reads an anchor missing from visibleIds as "no anchor" and replaces the selection with the clicked card, so shift-clicking dropped the still-visible cards too. Re-anchor onto the first still-rendered selected card, and carry hidden selections through a range so the query cannot silently discard them. A plain click still clears everything. Also, in the drop-index translation: - a lane filtered down to nothing now appends rather than always prepending (an empty rendered lane reports index 0 for every pointer position, so the old branch could only prepend, disagreeing with the document-drop path) - an unresolvable rendered id falls back toward the end of the lane its branch was aiming at, instead of sending every head drop to the bottom - the full-id channel uses NUL, the one character no path can contain, so serialization can no longer be defeated by a newline in a repo path. Dropping the channel was the wrong fallback: under a query the reader would scan the DOM and see only the matched cards. Tests now build the channel through its own serializer rather than hardcoding the delimiter. Co-authored-by: Orca * fix(kanban-search): explain a query discarded for length Past the palette byte bound the query is dropped and the board stays unfiltered, which looks identical to a query that matched everything — full field, untouched board, no counter. The field now marks itself invalid, shows a "Too long" badge carrying the full reason, and announces it. Whitespace-only text stays silent: it is also non-filtering, but self- evidently so. Co-authored-by: Orca * fix(kanban-search): derive the too-long badge from the deferred query The badge describes the board, so reading the live query made it flip a frame before the filter it is describing. Co-authored-by: Orca * fix(kanban-search): let a range replace a hidden selection like every other gesture Carrying hidden cards through a shift-click made it the only replace-shaped gesture that did so — a plain click and a non-additive marquee both drop them. It also left the user unable to narrow a selection: shift-clicking the two visible matches silently re-added the six hidden ones, and the badge counts only rendered cards, so nothing disclosed it. Re-anchoring onto the first still-rendered selected card, which is what actually fixed the collapse, is kept. Also state the Escape contract where a reader will look: SheetContent now declines Radix's dismiss explicitly instead of depending on handleSheetOpenChange quietly dropping the request, and the overlay reserve is capped so a wide counter in a narrow drawer cannot squeeze the typed text to nothing. The reserve is exported and tested directly — happy-dom cannot parse min(), so it could not be read back off a style. Co-authored-by: Orca * fix(kanban-search): stop mutating match-set ref during render React Doctor blocks ref writes during render; keep match-set identity stable with setState-during-render so discarded renders cannot leak it. --------- Co-authored-by: Orca * fix(worktrees): prevent deletion from blocking Orca (#11233) * fix(worktrees): prevent deletion from blocking Orca * test(worktrees): loosen async history-delete event-loop bound for CI The main-thread safety check failed on a loaded runner when a single timer gap hit ~48ms under the prior 30ms threshold. Keep the bound well below a recursive sync-rm stall without treating CI jitter as a block. * test(worktrees): measure history-delete critical path, not timer gaps setInterval gaps during async rm of thousands of files still flake under CI scheduling. deleteWorktreeHistoryDir is sync and must only rename, so assert that critical-path wall time stays well below a recursive walk. * fix(worktrees): prevent deletion from blocking Orca Add timeout-based draining of watcher closes so SSH round-trip delays don't indefinitely block the worktree removal path. Also: order durable temp-file sweeps ahead of writes to reclaim orphans before accumulation, skip own-process temps to avoid deleting live writes, swallow persistence errors so disk failures don't cascade to query callers, and measure history-deletion progress by loop turns rather than timer gaps to detect blocking on CI runners. * fix(worktrees): prevent deletion from blocking Orca Worktree deletion can now proceed even if filesystem watchers or history cleanup operations hang, preventing Orca from freezing. Changes: - Fence install slots with tokens instead of counters so removals can abandon wedged installs without corrupting later removals - Timeout-bound watcher unsubscribe operations with a shared drain budget - Move JSON serialization of large usage caches from queue-time to write-time to avoid blocking main thread - Async tombstone + schedule history tree deletion instead of blocking recursive rmSync during GC, preventing main-thread stalls ~10s after startup * Extract usage cache writer into reusable durable snapshot class Consolidates serialized durable-write and generation-veto logic from three usage stores into UsageCacheSnapshotWriter. Eliminates duplication, centralizes multi-MB JSON serialization on the main thread via write-queue serialization, and vetoes superseded snapshots to avoid wasted rewrites. * fix(worktrees): prevent deletion from blocking Orca Worktree deletion used to recursively delete large session trees (hundreds of MB) on the critical path, stalling the event loop. Instead, rename trees into a `.pending-delete` tombstone queue and reclaim them asynchronously off the removal's critical path. Extracted host tree removal into a reusable helper (`removeHostTree`) that centralizes Windows retry logic. Added usage-cache flush on quit to prevent data loss when scans complete right before shutdown. Improved watcher removal deadline management with reserved tail slices for the final unsubscribe, and added retry logic for tombstone removals that fail once under transient Windows locking. * fix(history): retry failed session tree removals Tombstoned session trees whose removal fails transiently (e.g., EBUSY under Windows AV) are now re-queued in-process with bounded exponential backoff instead of sitting until the next HistoryManager construction. Prevents a single stuck tree from blocking the entire Orca process. * fix(terminal): activate fresh OSC links on first click (#11453) * fix(main): prevent claude login hang on Windows due to inherited handles (#11407) * fix(native-chat): make the launch-draft mirror reachable (#11222) * fix(native-chat): make the launch-draft mirror reachable Seed the chat-composer copy of unsent launch context on every originating draft path, then let those launches open in chat by default. Three paths delivered a draft to the TUI without mirroring it into chat: folder-workspace create, the local argv-prefill branch of launchAgentInNewTab, and the web-host equivalent. The first was invisible; the other two were hidden only because draft launches were forced into terminal view. The view-mode decision now gates on the same predicate as seeding (canMirrorLaunchDraftToNativeChat), so a draft can never open in chat with a composer chat would refuse to fill. * fix(native-chat): gate draft view mode on argv-prefill launches too The draft view-mode gate read `startup.draftPrompt`, which only the post-ready-paste delivery sets. An argv-prefill launch carries its draft inside `launchCommand`, so the gate never saw one and the tab opened in chat unconditionally — a multi-line draft was correctly not seeded yet still opened chat, leaving an empty composer beside a filled TUI input. Adds `launchDraftText` to the activation startup payload as a view-mode-only field, deliberately distinct from `draftPrompt` so it cannot double-deliver the draft through pty-connection's bracketed paste, and sets it at all four originating producers. * fix(native-chat): reconcile backend draft launch tabs * fix(computer-use): make modifier clicks interruption-safe (#11451) * fix(computer-use): make modifier clicks interruption-safe * fix(computer-use): pace modified Windows multiclicks * fix(computer-use): address modifier safety review * fix(quick-open): support projects past 10k files (#11440) * fix(sidebar): float setup script prompt (#11439) * test(skills): pin both sides of the nested-skill prune boundary (#11462) The payload prune had only its miss side covered, so the bound could be raised or lowered by a refactor without anything failing. Both directions are now pinned: a skill is found through 2 intermediate directories below a package and missed at 3. Raising the bound spends the entry budget on vendor payload — the cost that made ordinary caches collapse and pin every skill amber (#10865). Missing a deeper copy costs only a Details row, since a plugin-cache placement is not convergeable by any update command. Recording the tradeoff on the constant so the next person to touch it knows which direction is the safe one. No behavior change. Closes #11454 * fix(project-host-setup): carry identity across hosts (#9413) Allow setup when the selected project exists only on another host by carrying its validated provider identity with the request instead of reverse-parsing project IDs. Preserve host-qualified provider identity and reject mismatched payloads before linking. Make linking atomic for local and runtime imports, including clone setup: roll back only newly registered repos and invalidate the same caches as canonical removal. Cover local, runtime, host-qualified identity, mismatch, clone rollback, and renderer routing paths. Co-authored-by: fanyunqian.1 * fix(computer): supervise Linux and Windows desktop providers from main (#11468) * fix(computer): supervise desktop providers from main * fix(computer): remove unreachable provider timeout mapping * test(computer): flush stale supervisor response * fix(tabs): trust native OpenCode titles without hook signals (#11382) * fix(tabs): trust native OpenCode titles * test(tabs): cover native OpenCode identity authority * fix(tabs): preserve sleeping provider identity * fix(tabs): preserve completed hook authority --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): keep a proxied wss host on :443 when editing (#11383) * fix(mobile): keep a proxied wss host on :443 when editing A host paired through a reverse proxy is stored as `wss://desk.example.com` with no explicit port. Editing it — even to only change the display name — rewrote the endpoint to `wss://desk.example.com:6768` and stranded the host, with no warning. `endpointPort` intentionally reports only explicitly written ports, so it returns undefined for that endpoint. The edit screen passed that undefined straight through as `fallbackPort`, where `resolveFallbackPort` substituted the LAN `DEFAULT_PORT`. Add `endpointPortOrSchemeDefault`, which falls back to the scheme's implicit port for wss and leaves bare ws alone so LAN pairings keep landing on DEFAULT_PORT, and use it for the edit screen's fallback. `normalizeHostEndpoint` is untouched — filling a missing port from `fallbackPort` is its documented contract and stays covered by its existing tests. * review(mobile): preserve untouched host endpoints * fix(mobile): preserve routed endpoint edits --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(runtime): avoid immediate WebSocket heartbeat sweep (#11300) * fix(runtime): avoid immediate WebSocket heartbeat sweep Defer the first heartbeat sweep until the interval tick. The immediate sweep can close a newly accepted WebSocket before the E2EE handshake completes on Linux ARM64. * test(runtime): update heartbeat expectations for deferred sweep * docs(runtime): update heartbeat initialization comment Clarified comment regarding socket pinging during heartbeat. * fix(runtime): arm heartbeat after socket listeners * test(runtime): pin shared heartbeat cadence * chore(runtime): preserve reliability gate formatting --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(computer): bind macOS helper to supervised peer pid (#11475) * feat(jira): link Jira issues from the workspace create dialog (#11296) * Link Jira issues from workspace create dialog Add Jira issue linking to workspace creation, matching existing GitHub and Linear workflows. Users can paste Jira issue URLs in the smart name field to auto-populate workspace names and link the issue to the created workspace/worktree. Linked Jira issues appear on workspace cards via the new 'jira-issue' card property. Implements cancellable searches and summary reads to prevent stalled requests from blocking the shared Jira pool. Persists paired issue + source context metadata with validation of provider/site identity. Fixes git-username rate-limit handling to reject malformed JSON responses so garbage never becomes branch prefixes. * feat(jira): link issues during workspace creation - Display linked Jira issues on worktree cards - Fetch issue summaries and timestamps via Jira API - Gate Jira linking behind runtime capability check - Preserve user-typed names during async lookups * Enforce git check-ref-format rules in login validation Extend isBranchSafeHostedLogin to reject usernames that git rejects as invalid branch components: trailing dots, consecutive dots, and .lock suffix. Prevents invalid branch names from login usernames. * Enforce filesystem filename cap for branch-safe logins Loose refs store logins as single filenames, so the real constraint is the 255-byte filesystem cap, not git check-ref-format rules. This allows longer provider-agnostic logins while staying platform-safe. * fix(setup-prompt): isolate state by execution host (#11447) Prevent setup prompt inspection, caching, dismissal, saves, telemetry, and settings navigation from leaking across local, direct SSH, and runtime-relayed hosts. * feat(feedback): attach images to feedback submissions (#10465) * feat(feedback): attach images to feedback submissions Users pasting screenshots into the feedback dialog were silently dropped: the textarea had no paste handler, the IPC payload had no image field, and the endpoint had nowhere to put one. Reports arrived saying "images attached" with nothing attached, which is why feedback-sourced tickets never have a screenshot to work from. Adds paste, drag-drop, and a file picker with thumbnail previews (up to 4 images, 8 MB each, png/jpeg/webp/gif). Rejected files raise a toast rather than disappearing — silent loss is the bug being fixed. Images ride the existing multipart lane, which previously activated only for crash diagnostic bundles. Crash submissions still drop images; that lane already carries bundles and the server rejects them there. When the server reports imagesDelivered: false the dialog says the feedback sent but the images did not, instead of a blanket success. A 2xx without the field counts as delivered so this keeps working against a server that predates the field. Requires the marketing-site half to deploy first. * copy(feedback): shorten attachment hint to 'Attach up to 4 screenshots' * fix(feedback): make dropped screenshots actually attach Three defects that discarded a user's image without telling them — the exact failure this feature exists to fix. Drag-and-drop never worked. `DataTransfer.files` is empty until the drop lands, so the dragenter guard always saw zero files and the highlight never armed. Worse, preload consumes native file drops on document capture with `stopPropagation()` and routes the paths to the editor, so React's `onDrop` never ran at all: dropping a screenshot on the dialog opened it in an editor behind the modal. The drop is now claimed one phase earlier on window capture and scoped to the dialog element, and the highlight keys off the drag types the OS advertises — matching useComposerFileDragOver and useSidebarProjectDrop. `crypto.randomUUID()` is undefined in non-secure browser contexts (the LAN web client over plain HTTP), so building draft ids with it rejected the read and dropped every image in the batch with no message and an unhandled rejection. Use createBrowserUuid, the repo's fallback for exactly this. `readFeedbackImageFiles` had no rejection handler, so any read failure (file removed after picking, permission error) silently lost the whole batch. Also: capacity was checked against a ref mirroring committed state, so two pastes landing during an in-flight read both saw room for four and the main process then rejected the entire submission; in-flight batches now count against capacity. And the non-en catalogs still carried the pre-amendment English copy for the attachment hint. * fix(feedback): close the prototype-chain hole in the image allow-list `contentType in FEEDBACK_IMAGE_EXTENSIONS` walks the prototype chain, so "constructor", "__proto__", "toString", "valueOf" and "hasOwnProperty" all cleared the allow-list. feedbackImageFilename then indexed the same object and named the upload after the inherited value — "feedback-image-1.function Object() { [native code] }" — and the part went out with that content type. Only reachable by invoking feedback:submit directly (the renderer screens types with Array.includes), which is exactly the threat model this function's own doc comment claims to cover. Object.hasOwn matches the 54 other uses in the repo and is identical for the four real types. The inherited values carry no quotes or CRLF, so this was a bypassed allow-list and a malformed upload, not multipart header injection. Adds unit coverage for the module, which had none, plus an IPC-level case; all six new assertions fail against `in`. * fix(feedback): accept the drag on dragover so the drop can fire The window-capture drop interception only fires if something first preventDefaults `dragover`. In Electron that comes free from preload's document-capture handler, but the same renderer is served to browsers as web-index.html, where `installWebPreloadApi` builds `window.api` in JS and installs no drag listeners at all. Nothing else in the renderer preventDefaults dragover for a native file drag. So on the web client the dialog is not a valid drop target: `drop` never fires and the browser falls back to its default action for a file dropped on a page — it navigates the tab to the file, taking the user's typed feedback with it. The new types-based dragenter guard makes this worse than before, because the highlight now arms and invites the drop that the old `files`-based guard could never light up. Mirrors useSidebarProjectDrop.onDragOver, which the drop rework already claimed to match. In Electron it is a harmless duplicate of the preventDefault preload already applied. * fix(feedback): revoke batch previews when a read rejects partway readFeedbackImageFiles creates the object URL for each accepted file as it goes. If a later file in the same batch fails `arrayBuffer()` — the removed-after-picking case the new rejection handler was added for — the whole promise rejects and the already-built drafts are never returned, so nothing ever revokes their previews. Each leaked URL pins its blob for the life of the renderer, up to three at 8 MB. Release them before rethrowing; the caller's rejection handler is unaffected. * fix(feedback): cancel non-image drops the dialog already accepted dragover advertises copy for every native file drag over the dialog, but drop only cancelled for images. On the web client an uncancelled drop navigates the tab to the file, taking the typed feedback with it. * fix(feedback): stop image validation from aborting crash reports buildSubmitBody drops images on the crash lane, but validation ran unconditionally, so a crash submission carrying an invalid image would have failed outright over attachments that were never going to be sent — losing a crash report the user needs delivered. Gate validation the same way body construction is gated. Not reachable today (the IPC handler forces submissionType 'feedback' and internal crash callers pass no images), but the two gates disagreeing is a trap for the next caller. Raised by CodeRabbit. Also documents why the image lane deliberately skips the 5xx retry the text lane performs: replaying up to 32 MiB on a flaky link costs more than it saves, and the dialog preserves the draft and thumbnails on failure. * fix(feedback): stop mutating the image-count ref during render React Doctor fails CI on "Ref mutated during render": the count was assigned in the component body, where React can discard or replay work that never commits. Read the committed count from the callback closure instead of a ref. Syncing the ref in an effect (the suggested fix) would reintroduce the race a previous commit removed — right after an add, the ref is stale-low until the effect flushes, so a paste in that window over-accepts and the main process rejects the whole submission. The closure value is always the committed count, and pendingImageReadsRef still covers in-flight reads. Costs a re-registration of the drop listeners per attach, which is the same teardown the hook already does when the dialog opens or closes. * fix(feedback): stop an unsupported pasted image from eating co-pasted text The paste handler consumed the event whenever the clipboard held any image/* file, but only the four allow-listed types can actually attach. Pasting text alongside an SVG or BMP therefore lost the text and attached nothing — a silent loss of the user's own input, in the dialog where they are mid-sentence. Consume the paste only when something is attachable. Unsupported types still route through readFeedbackImageFiles for their rejection toast, so nothing is dropped silently; the difference is that the default paste is left alone when we have nothing to offer in exchange. Extraction deliberately stays broad. Narrowing it there (as suggested by review) would skip handleAddFiles entirely, and a file paste into a textarea does nothing visible — the image would vanish with no feedback. The drop path is untouched: it must keep cancelling every native file drop or the browser navigates the tab to the file. * fix(feedback): stop the dialog accepting more than the endpoint will take The endpoint rejects reports over 5000 characters with a 400, which the dialog surfaces as a generic "Failed to submit feedback. Please try again." Nothing said length was the problem, so retrying could not help — the draft survived but the user had no way to know what to change. Cap the textarea at the same 5000 and show a counter once 500 characters remain, so the limit is visible before it bites rather than after. The counter stays hidden until then; an always-on count reads as a word limit to hit. Extracted rather than inlined: the dialog is already past the 300-line mark React Doctor warns on. * fix(feedback): prevent silent attachment loss * fix(feedback): improve attachment failure feedback * fix(feedback): bound attachment response parsing * fix(feedback): surface response body timeouts * fix(feedback): harden image delivery * fix(feedback): bound image preview resources * fix(feedback): honor atomic image delivery response Production’s single-message feedback endpoint uploads text and images atomically, then returns 202 {"ok":true} without an imagesDelivered field. Treating that omission as false warned users that every successful production attachment had failed. Treat a settled successful JSON response with ok: true and no image field as delivered. Explicit imagesDelivered: false still surfaces partial delivery, while malformed, oversized, aborted, and stalled bodies remain unconfirmed or fail through the existing response bound and timeout path. * revert: restore pre-worker process boundaries (#11481) * fix(remote): recover and safely park paired terminals (#11416) * fix(sidebar): isolate runtime reconnect refreshes (#11472) * fix(agent-hooks): skip unavailable agent homes (#11442) * fix(agent-hooks): skip unavailable agent homes * refactor(agent-hooks): separate Pi and OMP home fix * test(agent-hooks): update merged protocol harnesses * fix(agent-hooks): avoid redundant reconciliation * fix(agent-hooks): harden reconciliation and detection * test(agent-hooks): cover settings reconciliation * fix(agent-hooks): hydrate PATH for paired clients * fix(daemon): hand off slept v29 history to v30 (#11423) Co-authored-by: Orca * Fix stale agent icons in terminal tabs (#11484) * fix(tabs): prefer retained agent identity for icons * test(tab-bar): include retained agent store state * fix(terminal): log pane recovery at warn, not error (#10796) * fix(terminal): log pane recovery at warn, not error STA-2373 made this path routine: every daemon death now remounts each live pane, so error level floods logs and crash telemetry with a message that reports recovery *succeeding*. The breadcrumb right below is what diagnostics actually consume. * fix(terminal): correct recovery log comment and test console spy The comment claimed error level floods telemetry; nothing forwards renderer console into telemetry, and the breadcrumb below is untouched, so this change alters telemetry volume by zero. The test's console.error spy silenced the old call site and now stubs nothing, leaking 26 stderr lines under verbose. * fix(editor): save rich-markdown preview edits on blur, switch, and quit before the serialize debounce (#9730) (#9823) * fix(editor): flush markdown preview saves before teardown (#9730) * fix(editor): keep rich markdown blur saves policy-safe --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(floating-workspace): persist Markdown tab renames (#11398) * fix(floating-workspace): route markdown renames locally * test(floating-workspace): strengthen rename regression * test(floating-workspace): verify rename restart persistence * fix(filesystem): serialize local rename destinations * fix(filesystem): serialize Unicode rename aliases * fix(filesystem): align rename locks with native aliases * fix(filesystem): canonicalize rename parent locks --------- Co-authored-by: Dzmitry Bachko Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(runtime): skip unreadable Windows drives (#11421) Co-authored-by: Orca * fix: make remote server pairing failures actionable (#11510) * fix: make remote server pairing failures actionable * refactor: extract daemon router event types * fix: address remote pairing review findings * fix: address final remote pairing review feedback * fix(ui): right-align Project detail in new workspace combobox (#11521) Match Run on field layout so short provider details like stablyai/orca sit on the far right of the committed Project field instead of next to the name. * perf(orchestration): bound mutation ledger and run pages (#11432) * perf(orchestration): bound mutation ledger and run pages Co-authored-by: Orca * fix(orchestration): close retention pagination gaps * fix(orchestration): preserve unpaginated run listing Co-authored-by: Orca * fix(orchestration): reject malformed run cursors --------- Co-authored-by: Orca Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> * fix(daemon): split router subscription fanout (#11490) Co-authored-by: Orca * perf(terminal): serialize checkpoints with one payload walk (#11422) * perf(terminal): serialize checkpoints with one payload walk Co-authored-by: Orca * fix(terminal): bound checkpoint serialization Co-authored-by: Orca * test(terminal): correct bounded serialization proof Co-authored-by: Orca * test(terminal): cover over-limit multibyte checkpoints Co-authored-by: Orca --------- Co-authored-by: Orca * feat(settings): clarify Cloud VM setup (#11527) * fix(worktrees): stop terminals after external deletion (#11237) * fix(worktrees): stop terminals after external deletion * fix(worktrees): request teardown per caller and revalidate uncached Two defects let the original fix silently strand PTYs: - teardown rode the scan's coalescing promise, so any caller that joined an in-flight scan purged its renderer state without ever asking for a sweep; it now runs per caller against its own known-id snapshot, deduped on the request it actually produces so fan-out still shares one host sweep. - the runtime's authoritative recheck was served from the 30s worktree-scan cache, which can still list a directory git already dropped. The renderer purges either way, so a stale miss leaked those processes permanently. Co-authored-by: Orca * perf(worktrees): enumerate the host once per teardown sweep An agent cleaning up N workspaces made killAllProcessesForWorktree issue one full provider enumeration per missing worktree: O(N) relay round-trips carrying O(N^2) rows. At 30 worktrees over an 80ms-RTT SSH link that is 30 scans and ~1.3s of stalled teardown; it scales linearly from there. Share one point-in-time process list across the sweep — every worktree in it is already known-missing, so a single snapshot answers all of them. A failed scan is never shared: it falls back to a per-caller scan so one transient relay error cannot suppress the sweep for the whole batch. Pinned requirePhysicalStop:false since that path re-lists after shutdown and must not read a pre-shutdown snapshot. Co-authored-by: Orca * test(worktrees): pin the disconnected-SSH no-teardown invariant main's new directSshAuthority gate bails before any refresh when an SSH target is not connected. That is exactly the #10562 safety rule — "host unreachable" must never be read as "worktree deleted" — so pin it: a disconnected target issues no teardown RPC and keeps its renderer state. Co-authored-by: Orca * fix(worktrees): keep selector grammar intact when scoping by connection resolveRepoSelectorForConnection matched the selector as a bare repo id, so an explicit connection identity silently changed the grammar: `path:` and `name:` selectors resolved to repo_not_found on that path alone, losing the whole sweep. A connection identity should only *narrow* the candidate set. Extract the selector matching both paths now share, and stop re-resolving an already-resolved repo: teardown rescanned via `id:`, which throws selector_ambiguous when an id is duplicated across hosts even though the caller's own selector was unambiguous. Reported as a P2 by Greptile (as redundant work); it is load-bearing. Co-authored-by: Orca * fix(worktrees): keep the shared snapshot out of provider internals The snapshot proxy passed itself as the Reflect.get receiver, so prototype methods invoked through it ran with `this` bound to the proxy. A provider whose own shutdown() re-read state via `this.listProcesses()` would then silently get this sweep's cached snapshot instead of the live host — batching leaking past the calls it was built for. Bind non-listProcesses members to the target so only the sweep's own calls share the snapshot. No shipped provider does this today; the point is that adding one must not quietly change teardown semantics. Raised by Greptile as an undocumented implicit constraint; closed structurally rather than by comment. Co-authored-by: Orca --------- Co-authored-by: Orca * fix(sidebar): stop background workspace creation from scrolling the sidebar (#11530) * fix(sidebar): stop background workspace creation from scrolling the sidebar Creating a workspace in the background still spawns its terminals, and the renderer treated "no presentation stated" as "point the user at this terminal" -- revealing (scrolling to) the owning workspace. Split adoption from surfacing with an explicit surfaceOwner flag: background worktree creates and worker dispatch adopt their tabs silently, while `orca terminal create` keeps its discoverability reveal. * fix(sidebar): keep split-mode setup panes silent, tighten surfaceOwner Review catch: with setupScriptLaunchMode split-vertical/horizontal the Setup terminal goes through splitTerminal, whose reveal payload had no surfaceOwner, so a background create still scrolled the sidebar in that configuration. Also narrow surfaceOwner to `false` so "surface it" can only be expressed by omitting the key, and fold the repeated conditional spreads into ownerSurfacing. * fix(relay): refuse silent fallback when pairing invite fails (#11528) * fix(relay): refuse silent fallback when pairing invite fails When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options. * fix issues * fix(remote): preserve terminal recovery across control refresh (#11513) * fix(remote): recover stalled terminal streams * fix(i18n): localize manual disconnect error * fix(remote): park paired terminals with host snapshots * test(remote): mock authoritative resync snapshots * fix(terminal): defer startup mounts until hydration * fix(remote): raise paired terminal stream capacity * fix(remote): harden terminal recovery lifecycle * fix(remote): preserve calls across control refresh * test(remote): harden paired recovery oracle * test(workspace): seed Jira source context * test(remote): assert raw host terminal identities * test(terminal): keep restore sentinels atomic * test(terminal): keep restore sentinel on one row --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix: address pr-bug-scan validated finding from #7050 (#7066) Added open-only sessions re-poll (SESSION_LIST_POLL_MS) and clear sessionsError on popover close; blocks stale-session (C1) and stuck daemon-unreachable badge (C2). Co-authored-by: orca-bug-scan-bot * fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253) * fix(native-chat): mirror multi-line launch drafts into the chat composer seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline, so every Linear launch ("Linked Linear issue: X\n") and any GitHub launch with a typed note was invisible in chat. The rejection existed because the send path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with embedded newlines. Orca injects the draft itself, so when the composer still holds exactly what was injected the buffer already IS the message: the send becomes the submit key alone — no clear, no paste, nothing that can concatenate, and multi-line submits as one turn for free. Only the edited case needs real buffer replacement, and that now clears every line and verifies against the agent's rendered input line instead of firing blind. Measured on real PTYs against Claude Code and codex (both agree exactly): clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts for the law, the sequences that do NOT work, and why an upper bound is safe. * fix(native-chat): send the mobile clear burst as its own write Live QA caught the bundled form failing: a multi-line burst prefixed onto the body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters, so the parked draft survived and the message arrived as draft + 21x \x15 + body. Sending the burst as its own non-submitting write — the shape the image paste has always used — clears as intended. The body write's own single-Ctrl+U prefix is dropped once that dedicated clear ran, for the same reason: a Ctrl+U immediately followed by body text in one write lands as a literal control character and headed the received message. Re-verified live end to end: received prompt is exactly the draft, one turn, zero control characters. * test(native-chat): invert the multi-line Linear launch-draft mirror expectation The Linear work-item launch seeds `Linked Linear issue: ENG-42\n\n`. This test pinned the pre-relaxation rule (multi-line drafts withheld), which the send path no longer needs now that it submits the TUI buffer in place or clears every line first — so it asserted the exact behavior the fix removes. Assert the seeded payload instead of absence, so the test fails if the mirror regresses to single-line-only. * fix(native-chat): preserve launch draft send contents * fix(native-chat): preserve confirmed send queue ordering * fix(native-chat): preserve send pacing after renderer stalls * test(native-chat): align activation with multiline draft mirroring * fix(native-chat): clear launch drafts from any cursor * fix(native-chat): retire mobile-consumed launch drafts * test(mobile): stabilize QR capacity boundary fixture * fix(terminal): reduce inactive pane dimming (#11591) * feat(cli): add orca skills install and orca skills update for headless skill setup (#9201) Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path. **Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS. The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.** `universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list. Fixed during review — two holes that each restored the full fan-out through a different door: - `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`. - `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list. The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser. Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine. Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte. Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for. Co-authored-by: scastanoh21 * Update README downloads badge * release: v1.4.163-rc.0 * feat(cli): add `orca account add` / `account list` for headless hosts (Claude + Codex) (#9177) * feat(cli): add `orca account add` / `account list` for headless hosts The desktop "Add account" UI is disabled when the renderer drives a remote runtime (isRemoteAccountScope === kind:'environment'), so a headless server reached from a remote desktop/web client has no way to register managed Claude accounts. Add a host-local CLI path that reuses the existing capture logic: - ClaudeAccountService.addAccountFromConfigDir(): register a managed account by capturing credentials from an already-authenticated CLAUDE_CONFIG_DIR instead of spawning the interactive browser login (extracted persist/rollback helpers shared with the existing add flow) - RPC accounts.addClaudeFromConfigDir, bridged via OrcaRuntime; rejected for mobile device tokens (host-local only) - `orca account add` runs `claude login` in the user's own terminal into a temp CLAUDE_CONFIG_DIR, then registers it via the local runtime; `orca account list` lists managed accounts Switching (select) already works from a remote client; only adding was blocked. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): support Codex in `orca account add` / `account list` Mirror the Claude headless-account CLI for Codex: - CodexAccountService.addAccountFromHome(): register a managed Codex account by importing auth.json from an already-authenticated CODEX_HOME, reusing a shared persist helper extracted from doAddAccount (no interactive login spawned here) - RPC accounts.addCodexFromHome + OrcaRuntime.addCodexAccountFromHome bridge, rejected for mobile device tokens (host-local only) - `orca account add --agent claude|codex` (default claude); `orca account list` now renders both Claude and Codex managed-account blocks Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover headless account-add capture paths (Claude + Codex) - ClaudeAccountService.addAccountFromConfigDir: registers a managed account by capturing an authenticated CLAUDE_CONFIG_DIR; rejects and rolls back when the dir has no .credentials.json - CodexAccountService.addAccountFromHome: imports auth.json from an authenticated CODEX_HOME into a managed account; rejects when auth.json is missing Co-Authored-By: Claude Opus 4.8 (1M context) * fix: address CodeRabbit review on headless account-add flows - CLI login spawn uses a shell on Windows so `.cmd` agent shims resolve without ENOENT (args are fixed literals, no injection risk) - Claude capture skips the `.credentials.json` precheck on macOS, where creds live in the Keychain and captureAuthFromConfigDir reads them - Claude add rollback is best-effort: a failed rematerialization no longer skips managed-auth cleanup or masks the original add error - Codex persist restores the prior account/selection if a post-write sync or rate-limit refresh fails, so a failure can't leave a dangling managed account - Codex sync passes the account's selection target (correct runtime for WSL) - Add JSDoc to the new public service methods and CLI functions Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): harden headless account capture * fix(cli): correct account command flag surface and interrupt cleanup - `account` commands no longer accept or advertise the browser `--page` flag; `supportsBrowserPageFlag` allow-listed them by omission, so `orca account list --page x` was silently accepted and `--help` rendered a browser-only option - account specs declare GLOBAL_FLAGS, so `--help`/`--json` render in the Options block like every other command - `--agent` on `account add` documents the account provider instead of the terminal TUI-agent meaning inherited from the shared flag table - a SIGINT/SIGTERM during the interactive login now removes the temp login dir (and restores the macOS Keychain item) before exiting 130; Node terminates without unwinding `finally`, which stranded live OAuth credentials on disk * perf(cli): stop `account list` forcing a provider usage refresh `accounts.list` awaited refreshAccountsForMobile(), which runs fetchAll({ force: true }) — bypassing both the poll throttle and the per-provider Retry-After gate — then O(N) serial per-account round trips. `orca account list` renders only emails and the active ids, so all of that work was discarded. The RPC now takes `refreshUsage` (default true, so mobile and web keep the forced lane) and the CLI opts out. Older hosts declare `params: null` and ignore the field, so a newer CLI degrades to the previous behavior rather than failing. Also documents on `account list` that `--environment` does not retarget it, matching the host-local behavior of shouldIgnoreRemoteSelection. * fix(cli): survive repeated and hangup signals during account add withInterruptCleanup latched cleanup behind a boolean, so a second signal got an already-resolved promise and its process.exit fired while the first cleanup was still inside a Keychain call (3s each) — the temp dir's OAuth credentials and the swapped macOS Keychain item both survived. Memoize the cleanup promise so every signal awaits the same run, and register with `on` instead of `once` so a second Ctrl-C cannot fall through to Node's terminate-immediately default mid-cleanup. Handle SIGHUP too. This flow exists for headless/SSH hosts, where the most likely interrupt is the connection dropping, which hangs up the login's terminal and previously ran no cleanup at all. Warn when the interrupt lands after sign-in completed: the runtime finishes the add independently of this process, so exiting 130 silently would tell the user it was cancelled when the account may exist. Reject a valueless `--agent`; the parser turns it into boolean true, which silently ran a full OAuth login for Claude when the user asked for another provider. Also lock two behaviors the refactor changed but left uncovered: a WSL Codex add must sync the WSL runtime lane rather than the default host lane, and rename the account-spec help test to describe the Options block it actually asserts rather than the usage string it never reads. * fix(build): bundle the main modules the account CLI imports electron-vite cleans out/main and emits only its declared entries, and `build:desktop` runs it after `build:cli`, so the tsc-emitted copies of `claude-accounts/keychain`, `codex-cli/command` and `win32-utils` were deleted before packaging. Both `orca account add` and `orca account list` then died at require time with "Cannot find module '../../main/claude-accounts/keychain'" — reproduced against a real `--serve` host. `agent-hooks/managed-agent-hook-controls` already carried an entry for exactly this reason; these three were missing. Adds a parity test so any future CLI import of a `src/main` module fails in CI rather than at a user's shell after packaging. * test: cover the desktop add-path behavior this PR changes Both changes ride in the persist/rollback helpers the existing GUI add flow shares with the new headless path, and neither had coverage: - Claude: rollbackAddAccount now guards forceMaterializeCurrentSelection- ForRollback, so a rejecting rematerialization no longer replaces the real add error nor skips safeRemoveManagedAuth. Asserts the original error surfaces and the throwaway auth dir is gone. - Codex: the desktop add now passes the account's selection target to syncForCurrentSelection, matching reauthenticate and select. Asserts the host target alongside the existing WSL assertion. Both fail when the corresponding change is reverted. * fix(cli): close the remaining account-add interrupt and preflight gaps The round-1 interrupt fix detached the signal handlers before running the finally-path cleanup, so the very window it was meant to protect — the two serial 3s `security` calls plus rmSync on the success/error path — was still covered only by Node's terminate-immediately default. Both review lanes reproduced it independently. Await cleanup first, detach in a nested finally, and stop a cleanup failure from replacing the error that actually explains why the add failed. Do not burn the interactive login when the runtime is unreachable. The RuntimeClient is lazily constructed and the first call was the registration RPC itself, so "Requires the Orca runtime to be running" was discovered only after the user completed a full OAuth round trip. Preflight with the now-cheap `accounts.list { refreshUsage: false }`. Reject `--environment` / `--pairing-code` on `account add`. shouldIgnoreRemoteSelection pins account commands to the local runtime, so `orca account add --environment homelab` silently registered the account on the laptop instead of the headless host it names. Survive a daemon that cannot spawn `claude`. `allowFailure` is honored in onClose but not onError, and unlike the GUI flow nothing has run `claude` in the daemon before this point — so a launchd/systemd daemon with a minimal PATH hard-failed an add the user had already signed in for, even though identity resolves fine from the config dir's oauthAccount. Also align the `--agent` help description with the global flag column. * fix(cli): reject runtime selectors on `account list` too `orca account list --environment homelab` was accepted and silently listed the LOCAL machine's accounts, because shouldIgnoreRemoteSelection pins account commands to the local runtime. Documenting that in --help does not reach someone who already typed the flag, and answering with the wrong host's accounts is the specific wrong answer they would act on. `account add` already errors; this makes the new command group internally consistent. The other groups in shouldIgnoreRemoteSelection keep their existing silent-ignore behavior — changing those is not this PR's job. * test: harden account-add signal tests and cover cleanup failure - Identify the handler under test by set difference instead of `process.listeners(sig).at(-1)`. Vitest installs its own once-wrapped SIGINT teardown, so the positional lookup could grab the wrong listener; the helper also asserts exactly one new listener was added. - Mock rmSync while keeping the real implementation by default, so the temp-dir assertions elsewhere stay honest. - Cover that a cleanup failure in the `finally` does not replace the error explaining why the add failed. Fails when that guard is removed. Completes the review loop's final round; the loop died on an API error before it could commit this, and its `import()` type annotation would have failed oxlint. * fix(cli): harden interactive account add * test(cli): make account cancellation coverage portable * fix(cli): preserve merged skills runtime modules --------- Co-authored-by: Dominik Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(pty): do not create unused Pi/OMP home dirs on bare shells (#10198) * fix(pty): do not create unused Pi/OMP home dirs on bare shells Bare terminals used to materialize ~/.pi/agent and ~/.omp/agent (and install managed extensions) for possible later shell-launched agents. Users who never use those agents still saw the directories recreated after deletion. Only create the default agent home when launching that agent explicitly, or when the home already exists. Bare-shell OMP status still uses the userData fallback so typed `omp` keeps the shell wrapper extension. Closes #10196 * fix(relay): OMP bare-shell status fallback without ~/.omp CodeRabbit: relay materializePi returned null on bare shells with a missing OMP home, so SSH PTYs never set ORCA_OMP_STATUS_EXTENSION. Local already wrote a userData-managed status extension in that case. Write the status file under ~/.orca-relay/omp-managed-status-extension and return MaterializePiResult so relay.ts can export ORCA_OMP_STATUS_EXTENSION without ORCA_OMP_SOURCE_AGENT_DIR or creating ~/.omp. Also fix the local withOrcaManagedExtensionMarker typo on the bare-shell path. * fix(pty): only materialize Pi home for Pi launches --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * fix(mobile): keep closed sessions empty (#11251) * fix(mobile): stop re-creating a terminal when the session tab list empties The session route treated "zero session tabs" as "this workspace has never had anything" and auto-created a terminal. Closing the last tab prunes sessionTabs and nulls activeHandle locally, which is exactly that state, so the close was immediately followed by a brand-new terminal — and the guard re-arms on every route mount, so it recurs across visits (#9717, #7345). Gate the auto-create on whether this route has ever published a non-empty tab list for the workspace. A cold hydrate still gets its first terminal; an emptied list gets the empty state and its create button. Extracted to a hook because the route file sits at its max-lines cap; the call site is 4 counted lines smaller than the effect it replaces. * fix(mobile): keep emptied workspaces empty across visits * fix(mobile): reach the auto-create callbacks without a render-time ref write The hook kept `consumeCreationRoute`/`createTerminal` out of the effect deps by writing latest-refs during render. React can replay or discard a render, so the write can leak from UI that never commits — React Doctor flags it as a blocking "Ref mutated during render" error, which failed PR Checks' static analysis. useEffectEvent (React 19.2, already used in SourceControl.tsx) gives the same stable-callback-outside-deps behaviour with no render-time mutation. Retire the deprecated `MutableRefObject` for `RefObject` in the same pass. Retargets the source pin at the new wiring; test counts unchanged. * docs(mobile): document the two per-route reset contracts Both exported helpers exist for a non-obvious reason — they must be re-created or re-derived per worktree, or a reused route inherits the previous workspace's hydration state and the resurrection guard silently disarms. * fix(mobile): preserve terminal creation through reconnect * fix(mobile): reset reconnect attempts only after the E2EE handshake completes (#11465) ws.onopen zeroed reconnectAttempt before the handshake, so any endpoint that accepted the socket but never authenticated pinned the counter at 0-1: no escalation gate could fire, backoff never grew, and every screen showed "Connecting…" forever (issue #10119). Reset the counter on e2ee_authenticated instead, and make classifyConnection apply the warning/unreachable gates during connecting/handshaking so an escalated verdict latches through redials. * fix(mobile): close terminal session tabs authoritatively (#11240) * refactor(providers): split provider contract types by domain (#10434) types.ts sat 9 effective lines under the 300 max-lines cap, which forced #10065 to type onWriteUnavailable via a cast at the pty.ts call site. Split into pty-provider-contract.ts, filesystem-provider-contract.ts, and git-provider-contract.ts; types.ts stays a barrel owning IProviderRegistry and re-exporting every prior symbol, so no caller import changes. Largest new file is 105 effective lines. With headroom back, onWriteUnavailable is now declared on IPtyProvider (optional — only respawnable endpoints like the daemon adapter signal it) and the pty.ts cast is gone. * fix(skills): stop OS sidecars marking an untouched skill as modified (#11471) * fix(skills): stop OS sidecars marking an untouched skill as modified Package identity compared a live user directory against a tree read from a clean checkout, so anything the OS deposited counted as drift. One Finder visit writes .DS_Store, which sorts before SKILL.md and misaligns the index-aligned snapshot comparison — the copy became 'unrecognized', was reported as "may be modified... Remove it", and left out of the update. Running the update could not clear it either: the updater compares its lock to the source and never reads disk, so it correctly reports "up to date" and writes nothing. Ignore OS-authored names on both sides of the comparison. The generator half is not hypothetical: a stray sidecar in a working tree made the committed artifacts read as stale, failing lint for that developer. Scoped to OS-authored names only. Tolerating unexpected files in general would let an injected payload ride along beside a clean SKILL.md; these are safe because an official SKILL.md never references them, so no agent can be routed into one. Mode bits are deliberately untouched — that would weaken identity for real scripts. * fix(skills): keep guarding a directory or link wearing an OS metadata name The name-only skip dropped any entry matching an OS metadata name, so a directory named .DS_Store or ._scripts took its whole subtree out of identity and a symlink wearing one stopped tripping the link guard — a skill hiding either read as pristine. The OS writes these as plain files only, so the entry type decides, still ahead of the case-fold map. Also compares both walkers over the same fixture: an asymmetric skip is worse than none, since one side would bake in content the other can never observe. * chore: ignore the OS metadata names skill identity already skips Both skill-identity walkers ignore these names, but .gitignore covered only .DS_Store and Thumbs.db — so a stray ._SKILL.md showed as untracked and `git add -A` could commit it. That is the one way the two walkers can disagree: the disk walker skips such a file while the git-tree producer (collectGitPackageFiles, used by the unreferenced --rebuild-from-tags path) does not, so a committed sidecar would make released history and observation describe different content. Ignoring them keeps that asymmetry unreachable rather than adding a second skip to the released-history path, which is load-bearing and provably never sees one today: no committed sidecar exists on any ref. Nothing tracked matches the new patterns. * chore: correct the skill-identity ignore comment The previous wording claimed these names cannot be committed, which overstates what .gitignore provides: `git add -f` and `git apply --index` both bypass it, so a cherry-pick, rebase or fork branch already carrying a sidecar is unaffected. That clause was load-bearing — it was the stated reason for leaving the released-history producer unhardened — so it should not read as a structural guarantee. Also fixes the producer count (three, not two: two disk walkers plus the git-tree producer, which does not skip) and says plain file, since the skip is isFile()-gated so a directory or link wearing the name is still walked. * fix(skills): stop a project's own skill copy driving global freshness (#11474) * fix(skills): stop a project's own skill copy driving global freshness A pristine global install plus a drifted copy inside a work directory showed Settings -> Computer Use as amber "Needs attention", with the copy labelled "may be modified ... Remove it if you want Orca to update this skill", while the same page said "Computer Use is ready" and the update command correctly printed "All global skills are up to date". No user action could clear it. Orca's updater only ever passes --global, so a copy a project owns has no remedy by design. Two defects made it drive the global verdict anyway: - locationChip tested byte status before topology, so an unrecognized repo-scope placement returned early and never reached its 'in-a-repo' case. Because SKIPPED_REASON_PRIORITY ranks unrecognized above in-a-repo, the summary sentence was wrong too. - isSkillCopyNeedingAttention excepted plugin-cache but not repo-scope. Stated by scope rather than by byte status: an outdated or unreadable project copy is as far outside the global updater's reach as an unrecognized one, so pinning only the reported status would leave the same bug reachable through another. Chip precedence is now explicit -- a read failure outranks ownership so that rule cannot hide a real fault, and ownership outranks byte status. Ownership suppresses the group, never a location's visibility: a project copy is still listed whenever another placement earns the row. The badge predicate deliberately omits the shared helper's outdated carve-out, so a non-eligible outdated copy stays amber. Collapsing the two into one predicate would flip that to green while the dialog still shows its reinstall row, so the distinction is preserved and pinned by a regression guard. Eligibility needed no change: it already filters to convergent placements. * fix(skills): keep a project copy from explaining a global skill's skip Review follow-up. The chip and the group no longer treat a project-owned copy as global drift, but three surfaces still read it as one: - skippedReason derived its one sentence from the highest-priority chip among a group's locations, with 'in-a-repo' ranked above 'duplicate', 'external-link', 'broken-link' and above the no-chip case that hands over the reinstall command. A repo-scope copy can no longer earn a group, so whenever it won it explained a skip it had no part in — and swallowed the one runnable remedy. SkillLocationRow now carries whether the update judged it, and only judged rows explain. That also covers the scan-limit sentinel, which is repo-scope and chips 'inaccessible'. - hasSkillCopyNeedingAttention counted project copies as the presence that makes a plugin-cache read failure a skill's problem, while the status function skipped them — the disagreement the two exist to prevent. - The nudge mixed project copies into its dismissal fingerprint, so re-checking out a repo re-raised a nudge the user had already dismissed. plugin-cache is untouched: it stays on the judged side everywhere, because updating the plugin is a remedy a project copy does not have. * fix(checks): rank successful checks above skipped and neutral (#11337) * fix(checks): rank successful checks above skipped and neutral Checks were ordered with `skipped` (4) and `neutral` (3) ahead of `success` (5), so a PR with a long tail of skipped jobs pushed every passing check below the fold — you scroll past a wall of "Skipped" to find out whether anything actually ran. Rank the no-signal conclusions last (`success` 3, `neutral` 4, `skipped` 5) and pull the order out of its three duplicated copies (checks-panel-content, PullRequestPage, GitHubItemDialog) into `src/shared/pr-check-severity-order.ts`. Unknown conclusions now sink to the bottom instead of silently ranking as `neutral`. * fix(checks): look up check ranks through a Map, not an object literal An object-literal rank table resolves `constructor`, `toString`, and `__proto__` off Object.prototype, so those keys returned a function instead of falling through to UNKNOWN_CHECK_RANK — the comparator then subtracted functions, went NaN, and left the list in arbitrary order. Conclusions come from provider payloads, so keep the lookup on a Map and cover prototype property names in the test. * test(checks): cover provider-neutral ordering states * fix(checks): preserve actionable provider states * fix(checks): preserve unresolved provider rollups * fix(checks): keep unknown GitLab rollups neutral * fix: preserve neutral review check summaries * fix: complete provider-neutral check ordering remediation * fix: use provider-neutral mobile review status input * fix: hydrate GitLab mobile review status * fix: type mobile GitLab review hydration --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(editor): map .cts/.mts to the typescript language id (#11294) * fix(editor): map .cts/.mts to the typescript language id The comment above EXT_TO_LANGUAGE already documents that Monaco maps .tsx/.cts/.mts onto the typescript language id, but only .tsx was in the table, so .cts/.mts files opened as plaintext. * fix(mobile): map cts and mts to typescript --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): recover when half-open sockets omit close events (#11368) * fix(mobile): recover half-open RPC sockets * test(mobile): assert reconnect attempt reset * fix(mobile): coalesce half-open recovery probes --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(mobile): render the terminal caret for main-buffer TUIs (Claude Code) (#11387) * fix(mobile): render the terminal caret for main-buffer TUIs The mobile WebView never flipped xterm's isCursorInitialized, which both renderers check before they ever read cursorStyle/cursorInactiveStyle. The native TextInput owns keyboard focus and xterm's textarea is inert, so the focus and keydown paths never fire, leaving DECSET 1049 as the only way to flip it. Alt-screen TUIs got a caret as a side effect; Claude Code, which redraws its composer in the main buffer, never did. Set showCursorImmediately so the caret does not depend on focus, and switch cursorInactiveStyle to block: mobile is permanently unfocused, so that option is what renders, and a bar is dpr device px wide and disappears under the fit scale() the WebView applies. Refs #8313, #7093 * test(mobile): prove main-buffer caret rendering * test(mobile): calibrate terminal listener cleanup * test(mobile): keep caret oracle teardown assertion-free --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(test): stabilize system SSH transport integration (#11597) * fix(test): stabilize system SSH transport integration * fix(lint): extract terminal display mode predicate * fix(test): exercise fake relay socket bridge * fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) (#11351) * fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) The browser pane's renderer-path Find handler is a window-global capture-phase keydown listener, but it armed on `isActive` (the active tab within its own group) rather than on whether its split holds focus. In a terminal+browser split, the browser was therefore `isActive` even while the terminal held keyboard focus, so it swallowed Cmd/Ctrl+F and opened find-in-page in the browser instead of find-in-terminal. Thread a focused-split signal (`isFocused`) from BrowserPaneOverlayLayer — derived from `activeGroupIdByWorktree` — down to the Find handler and gate the listener on it. This mirrors how terminal leaves already gate global shortcuts via `focusedGroupId` in TabGroupSplitLayout. Floating browser panels omit the prop and fall back to `isActive`, preserving their behavior. The IPC path (webview guest focused) is unchanged; it only fires when the guest genuinely has focus. Not platform-specific: the chord resolves through `keybindingMatchesAction` (Mod -> metaKey on macOS, ctrlKey elsewhere), so the same path is fixed on macOS, Linux, and Windows. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(browser): preserve Find before split focus settles * fix(browser): handle stale focused split IDs * fix(browser): route guest Find to source page * test(browser): wait for split address bar * test(browser): focus split before Find routing --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * fix(ssh): preserve first config directive value (#11297) * fix(ssh): preserve first config directive value * test(ssh): cover false-first config booleans * fix(ssh): trust fresh OpenSSH config authority * fix(ssh): preserve ordered config identities --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Add audit-only daemon incarnation evidence (#11606) * feat(daemon): add audit incarnation evidence * fix(daemon): isolate audit evidence observers * fix(status-bar): align usage density modes (#11376) * fix(status-bar): align usage density modes * fix(status-bar): reuse cataloged usage mode label --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> * Unify Linear and task provider setup into guided flows (#11533) * refactor(settings): unify Linear setup into guided flows across Task Sou Consolidate Linear API connection, agent skill installation, and Tasks visibility into guided step-by-step workflows. Moves Linear setup from a hidden integrations link into expandable cards within Task Sources (first-time path) and a prominent checklist in the Linear settings pane. Adds reusable TaskSourceProviderCard and step components so GitHub, GitLab, and Jira follow the same pattern. Surfaces incomplete provider warnings and auto-expands the first unfinished provider to reduce friction. * refactor(settings): unify Linear setup into guided flows across Task Sou Extract shared Linear skill setup logic into `useLinearAgentSkillSetup` hook so Task Sources and LinearAgentSkillPane both follow the same configuration path. Embed the skill install panel inside LinearAgentSkillGuide instead of placing it after, eliminating duplicate "Agent skill" section headers. Introduce task provider setup state helpers: distinguish fresh untouched providers from started-then-stalled ones (only warn on the latter), keep auto-expanded cards open across rechecks via sticky tracking, and handle unavailable/failed preflight status. Rename BrowserUseStepBadge to SetupStepBadge (used by multiple setup types now). Route connected credential management to Integrations cards instead of re-opening dialogs. Add settled flag to skill discovery so focus-triggered rescans don't flash UI on known results. * chore: credit task source guidance contribution Co-authored-by: Chen * fix: clear loading state regardless of generation's showLoading flag When a silent refresh supersedes an in-flight focus rescan, the losing rescan's finally block writes are dropped by the generation guard. Only the winning generation clears loading, so it must clear regardless of its own showLoading setting. Also move TasksPane auto-expanded tracking from effect to render phase to prevent layout-effect re-renders from seeing an unclaimed slot and collapsing the card. * fix: stop mutating refs during render for React Doctor Replace render-time ref writes in TasksPane sticky auto-expand and active project skill runtime identity caching with setState-during-render so static analysis can pass without changing behavior. --------- Co-authored-by: Chen * fix(win): harden startup during partial updates (#11613) * fix(sidebar): let the scroll anchor follow a re-keyed row (#11543) * fix(daemon): preserve audit evidence polarity (#11626) * fix(editor): drop the unreachable uppercase .R extension key (#11322) detectLanguage lowercases the extension before the table lookup, so the '.R' entry can never be reached — '.r' already covers every casing. It is also the only key in the table with an uppercase character, and leaving it suggests uppercase extensions need their own rows. * fix(worktree): warn when remote base falls back locally * fix(worktree): keep warning render pure --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Mark Xian Co-authored-by: OrcaWin Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Yoyo <22124891+yoyoys@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Co-authored-by: Orca Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Co-authored-by: Wooseong Kim Co-authored-by: zbisure <13783310+zbisure@users.noreply.github.com> Co-authored-by: 陈泽榜 Co-authored-by: buf0-bot[bot] <252831055+buf0-bot[bot]@users.noreply.github.com> Co-authored-by: orca-bug-scan-bot Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Co-authored-by: feelgom Co-authored-by: waryan <60338207+forwaryan@users.noreply.github.com> Co-authored-by: Claude Co-authored-by: 余辉 <42717106+OnlyYu1996@users.noreply.github.com> Co-authored-by: BingZ Co-authored-by: Rod Boev Co-authored-by: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com> Co-authored-by: Michael <123170392+MichaelHoughtonDeBox@users.noreply.github.com> Co-authored-by: caioribeiroclw-pixel Co-authored-by: Dhilip Subramanian <49802211+sdhilip200@users.noreply.github.com> Co-authored-by: JeongUk Park Co-authored-by: Vladislav Meshkorudnyj Co-authored-by: vladmesh Co-authored-by: Kevin Bravo <79945749+0bkevin@users.noreply.github.com> Co-authored-by: hanjoonchoe Co-authored-by: Sebastian <165098826+stroland02@users.noreply.github.com> Co-authored-by: Yunqian Fan Co-authored-by: fanyunqian.1 Co-authored-by: ye4241 <6803102+ye4241@users.noreply.github.com> Co-authored-by: jmdall <862663+jmdall@users.noreply.github.com> Co-authored-by: Dzmitry Bachko Co-authored-by: Dzmitry Bachko Co-authored-by: Sebastián Castaño <156130631+scastanoh21@users.noreply.github.com> Co-authored-by: scastanoh21 Co-authored-by: Dominik Mery Co-authored-by: Dominik Co-authored-by: KyuJoo Han Co-authored-by: TaeHwan Jung Co-authored-by: Appcaster Co-authored-by: Aleksandar Mirilovic Co-authored-by: Henry Su Co-authored-by: Hyein Cho <142622296+hyein-cbio@users.noreply.github.com> Co-authored-by: Chen Co-authored-by: TaeHwan Jung --- src/main/ipc/worktree-remote.ts | 68 +++++++++---- src/main/ipc/worktrees.test.ts | 75 +++++++++++++-- src/renderer/src/App.tsx | 2 + .../WorktreeBaseFallbackDialog.test.tsx | 95 +++++++++++++++++++ .../components/WorktreeBaseFallbackDialog.tsx | 81 ++++++++++++++++ .../worktree-base-fallback-notice.ts | 35 +++++++ src/renderer/src/i18n/locales/en.json | 5 + .../src/store/slices/worktrees.test.ts | 24 +++++ src/renderer/src/store/slices/worktrees.ts | 4 + src/shared/types.ts | 6 ++ 10 files changed, 370 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx create mode 100644 src/renderer/src/components/WorktreeBaseFallbackDialog.tsx create mode 100644 src/renderer/src/components/worktree-base-fallback-notice.ts diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4a4dcd04bfe..3be2a173ee1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -17,6 +17,7 @@ import type { LocalBaseRefUpdateSuggestion, Repo, Worktree, + WorktreeCreateBaseFallback, WorktreeHeadIdentity, WorktreeMeta } from '../../shared/types' @@ -1575,8 +1576,32 @@ export async function createRemoteWorktree( 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' ) } - const { baseBranch } = basePlan + let { baseBranch } = basePlan let { remoteTrackingBase } = basePlan + let baseFallback: WorktreeCreateBaseFallback | undefined + + if (remoteTrackingBase) { + const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + provider, + repo.path, + remoteTrackingBase.ref + ) + const hasNamedLocalBaseRef = await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasRemoteWorktreeBaseRef(provider, repo.path, remoteTrackingBase.branch)) + if (!hasRemoteTrackingBaseRef && (hasNamedLocalBaseRef || hasFallbackLocalBaseRef)) { + // Why: branch reuse and conflict checks must see the local fallback too. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } + remoteTrackingBase = null + } + } let branchName = '' let checkoutExistingBranch = false @@ -1683,19 +1708,6 @@ export async function createRemoteWorktree( // Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) - if (remoteTrackingBase) { - const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( - provider, - repo.path, - remoteTrackingBase.ref - ) - const hasLocalBaseRef = - hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch)) - if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { - remoteTrackingBase = null - } - } - if (remoteTrackingBase) { try { await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) @@ -1933,6 +1945,7 @@ export async function createRemoteWorktree( ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}), + ...(baseFallback ? { baseFallback } : {}), timing: timing.finish() } } @@ -1971,7 +1984,7 @@ export async function createLocalWorktree( ? await resolveLocalGitUsername(repo.path) : '' - const baseBranch = await resolveWorktreeCreateBase({ + let baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), @@ -2010,6 +2023,7 @@ export async function createLocalWorktree( } let remoteTrackingBase: RemoteTrackingBase | null = null + let baseFallback: WorktreeCreateBaseFallback | undefined let remoteTrackingRefresh: { base: RemoteTrackingBase hadLocalBaseRef: boolean @@ -2029,10 +2043,29 @@ export async function createLocalWorktree( remoteTrackingBase, ...localWorktreeGitOptionArgs ) + const hasNamedLocalBaseRef = await hasLocalWorktreeBaseRefWithOptions( + repo.path, + baseBranch, + localGitExecOptions + ) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasLocalWorktreeBaseRefWithOptions( + repo.path, + remoteTrackingBase.branch, + localGitExecOptions + )) const hasLocalBaseRef = - hasRemoteTrackingBaseRef || - (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)) + hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + // Why: use the usable local branch when offline refresh cannot create its tracking ref. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } remoteTrackingBase = null } else { emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) @@ -2583,6 +2616,7 @@ export async function createLocalWorktree( ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), + ...(baseFallback ? { baseFallback } : {}), ...(stagedStartup.warning ? { warning: appendWorktreeCreateWarning(includeCopyWarning, stagedStartup.warning) } : includeCopyWarning diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index d8f363a3fc4..2dec930beaf 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -5572,10 +5572,10 @@ describe('registerWorktreeHandlers', () => { getActiveMultiplexerMock.mockReturnValue(mux) store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'slash-local-base' - }) + })) as CreateWorktreeResult expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', @@ -5591,6 +5591,10 @@ describe('registerWorktreeHandlers', () => { base: 'team/feature' } ) + expect(result.baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) }) it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { @@ -6396,13 +6400,16 @@ describe('registerWorktreeHandlers', () => { return { stdout: 'created-sha\n', stderr: '' } }) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'slash-local-base' - }) - ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'slash-local-base' + }) + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -6413,6 +6420,58 @@ describe('registerWorktreeHandlers', () => { ) }) + it('uses a local branch when its missing remote-tracking base cannot refresh', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/offline-local-main', + head: 'created-sha', + branch: 'offline-local-main', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: '', stderr: '' } + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'offline-local-main', + baseBranch: 'origin/main' + }) + + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'origin/main', + localRef: 'main' + }) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/offline-local-main', + 'offline-local-main', + 'main', + false + ) + }) + it('keeps an explicit base strict when the pre-create refresh fails', async () => { const remoteBase = { remote: 'origin', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1d90f1cf5e0..e5b5d7387d8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -205,6 +205,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { hasRequestedBackgroundTerminalWorktreeMount, @@ -2762,6 +2763,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx new file mode 100644 index 00000000000..2016fa4072a --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import WorktreeBaseFallbackDialog from './WorktreeBaseFallbackDialog' +import { + requestWorktreeBaseFallbackNotice, + resetWorktreeBaseFallbackNoticesForTests +} from './worktree-base-fallback-notice' + +const initialState = useAppStore.getInitialState() +let root: Root | null = null + +async function renderDialog(activeModal: AppState['activeModal'] = 'none'): Promise { + useAppStore.setState({ + activeModal, + setContextualToursBlockingSurfaceVisible: vi.fn() + }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +describe('WorktreeBaseFallbackDialog', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + resetWorktreeBaseFallbackNoticesForTests() + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + resetWorktreeBaseFallbackNoticesForTests() + useAppStore.setState(initialState, true) + }) + + it('explains the fallback and advances queued notices', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + requestWorktreeBaseFallbackNotice({ + requestedRef: 'upstream/develop', + localRef: 'develop' + }) + await renderDialog() + + expect(document.body.textContent).toContain('Workspace created from a local base') + expect(document.body.textContent).toContain( + 'The remote-tracking ref "origin/main" was unavailable' + ) + expect(document.body.textContent).toContain('may not include the latest remote changes') + + await act(async () => { + getButton('Got it').click() + }) + + expect(document.body.textContent).toContain('"upstream/develop" was unavailable') + }) + + it('waits until another app modal closes', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + await renderDialog('new-workspace-composer') + + expect(document.body.textContent).not.toContain('Workspace created from a local base') + + await act(async () => { + useAppStore.setState({ activeModal: 'none' }) + }) + + expect(document.body.textContent).toContain('Workspace created from a local base') + }) +}) diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx new file mode 100644 index 00000000000..e64e5904511 --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useSyncExternalStore } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + dismissWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + subscribeWorktreeBaseFallbackNotice +} from './worktree-base-fallback-notice' + +export default function WorktreeBaseFallbackDialog(): React.JSX.Element { + const notice = useSyncExternalStore( + subscribeWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice + ) + const activeModal = useAppStore((state) => state.activeModal) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const lastNoticeRef = useRef(notice) + const displayedNotice = notice ?? lastNoticeRef.current + const open = notice !== null && activeModal === 'none' + + useEffect(() => { + if (notice) { + lastNoticeRef.current = notice + } + }, [notice]) + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(open) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [open, setContextualToursBlockingSurfaceVisible]) + + return ( + { + if (!isOpen) { + dismissWorktreeBaseFallbackNotice() + } + }} + > + + + + + {displayedNotice ? ( + + {translate( + 'auto.components.WorktreeBaseFallbackDialog.description', + 'The remote-tracking ref "{{value0}}" was unavailable, so Orca used local "{{value1}}" instead. This workspace may not include the latest remote changes.', + { value0: displayedNotice.requestedRef, value1: displayedNotice.localRef } + )} + + ) : null} + + + + + + + ) +} diff --git a/src/renderer/src/components/worktree-base-fallback-notice.ts b/src/renderer/src/components/worktree-base-fallback-notice.ts new file mode 100644 index 00000000000..fc314732b4a --- /dev/null +++ b/src/renderer/src/components/worktree-base-fallback-notice.ts @@ -0,0 +1,35 @@ +import type { WorktreeCreateBaseFallback } from '../../../shared/types' + +const notices: WorktreeCreateBaseFallback[] = [] +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function requestWorktreeBaseFallbackNotice(notice: WorktreeCreateBaseFallback): void { + notices.push(notice) + notifyListeners() +} + +export function dismissWorktreeBaseFallbackNotice(): void { + if (notices.shift()) { + notifyListeners() + } +} + +export function getWorktreeBaseFallbackNotice(): WorktreeCreateBaseFallback | null { + return notices[0] ?? null +} + +export function subscribeWorktreeBaseFallbackNotice(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetWorktreeBaseFallbackNoticesForTests(): void { + notices.length = 0 + notifyListeners() +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9706e8f1fb7..edc3de12f21 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14343,6 +14343,11 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "WorktreeBaseFallbackDialog": { + "title": "Workspace created from a local base", + "description": "The remote-tracking ref \"{{value0}}\" was unavailable, so Orca used local \"{{value1}}\" instead. This workspace may not include the latest remote changes.", + "dismiss": "Got it" } }, "i18n": { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index dbcbff9f74e..44f45376fcf 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -31,6 +31,8 @@ import { markHugeRepoWarningDismissed } from '@/lib/source-control-huge-repo-warning-dismissals' +const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) + vi.mock('sonner', () => ({ toast: { warning: vi.fn(), @@ -41,6 +43,10 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice +})) + const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() const worktreeListMock = vi.fn().mockResolvedValue([]) @@ -3969,6 +3975,24 @@ describe('createWorktree base status merge', () => { }) }) + it('requests a warning dialog when creation falls back to a local base', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1' + }) + const baseFallback = { + requestedRef: 'origin/main', + localRef: 'main' + } + mockApi.worktrees.create.mockResolvedValue({ worktree: wt, baseFallback }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(requestWorktreeBaseFallbackNotice).toHaveBeenCalledWith(baseFallback) + }) + it.each([ { status: 'skipped_dirty_worktree', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 27f04a3c8eb..f51e76fef6e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,7 @@ import { } from '@/lib/source-control-huge-repo-warning-dismissals' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' +import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -3785,6 +3786,9 @@ export const createWorktreeSlice: StateCreator } }) showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + if (result.baseFallback) { + requestWorktreeBaseFallbackNotice(result.baseFallback) + } showLocalBaseRefUpdateSuggestionToast(result.localBaseRefUpdateSuggestion, { updateSettings: get().updateSettings, getSettings: () => get().settings, diff --git a/src/shared/types.ts b/src/shared/types.ts index 5e61921779d..b8acdc76d4b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2286,6 +2286,7 @@ export type CreateWorktreeResult = { } defaultTabs?: WorktreeDefaultTabsLaunch warning?: string + baseFallback?: WorktreeCreateBaseFallback initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion @@ -2300,6 +2301,11 @@ export type CreateWorktreeResult = { timing?: WorktreeCreateTiming } +export type WorktreeCreateBaseFallback = { + requestedRef: string + localRef: string +} + export type PreservedWorktreeBranch = { branchName: string head?: string From 8eee7f039ab2af1d125ba4ca20a18c0ed4cc0a1a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:42:10 -0700 Subject: [PATCH 031/123] [P0] fix(relay): survive PTY exit after a canceled source delivery (#11634) * fix(relay): survive PTY exit after a canceled source delivery A client cancel (or reconnect-grace expiry) closed the source-credit ledger delivery without retiring the publication record, so the next pty.exit sealed a dead ledger entry and threw out of onExit into uncaughtException, killing the relay daemon. Five coordinated layers: - A/A': adapter-side cancellations now fire onCreditAvailable so pruneClosed retires the record at cancel time, suppressed while an exit frame is in flight. - B0-B3: settled-first short-circuit in publishPendingExit, a tolerant entry guard in sealAndPublishPtySourceExit that separates healthy completion from cancellation, same-client re-attach healing, and deferred retire-on-append. - C: publishPendingExit's source branch cannot propagate a fault out of onExit. - D: the exit settlement closure probes before settling and never throws out of a bare socket write/drain callback. Adds the non-throwing snapshotIfKnown ledger probe and routes every caller that can race a close through it. * fix(relay): preserve partial PTY exit progress --- .../pty-handler-source-publication.test.ts | 160 ++++++++- src/relay/pty-handler.ts | 31 +- .../pty-source-credit-availability-notice.ts | 19 ++ src/relay/pty-source-credit-ledger.test.ts | 35 ++ src/relay/pty-source-credit-ledger.ts | 19 +- src/relay/pty-source-credit-record.ts | 21 +- ...relay-pty-source-cancellation-exit.test.ts | 308 ++++++++++++++++++ .../relay-pty-source-exit-publication.test.ts | 260 +++++++++++++++ .../relay-pty-source-exit-publication.ts | 58 +++- src/relay/relay-pty-source-output.ts | 63 ++++ .../relay-pty-source-publication.test.ts | 18 +- src/relay/relay-pty-source-publication.ts | 72 ++-- .../relay-pty-source-send-scheduler.test.ts | 155 +++++++++ src/relay/relay-pty-source-send-scheduler.ts | 41 ++- src/relay/ssh-pty-consumer-session-adapter.ts | 6 + .../ssh-pty-source-credit-adapter.test.ts | 84 +++++ src/relay/ssh-pty-source-credit-adapter.ts | 21 +- 17 files changed, 1291 insertions(+), 80 deletions(-) create mode 100644 src/relay/pty-source-credit-availability-notice.ts create mode 100644 src/relay/relay-pty-source-cancellation-exit.test.ts create mode 100644 src/relay/relay-pty-source-exit-publication.test.ts create mode 100644 src/relay/relay-pty-source-send-scheduler.test.ts diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index 982b64d5c54..282d9f536db 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -56,6 +56,11 @@ describe('PtyHandler negotiated source publication', () => { let heldResponseSettlements: ((result: SinkWriteSettlement) => void)[] let adapter: SshPtyConsumerSessionAdapter let pausePty: ReturnType + let exitCallback: ((event: { exitCode: number }) => void) | undefined + let destroyPty: ReturnType + let holdDataSettlements: boolean + let heldDataSettlements: ((result: SinkWriteSettlement) => void)[] + let highWaterMark: number | undefined beforeEach(async () => { vi.useFakeTimers() @@ -64,20 +69,28 @@ describe('PtyHandler negotiated source publication', () => { heldResponseId = null heldResponseSettlements = [] dataCallback = undefined + exitCallback = undefined + holdDataSettlements = false + heldDataSettlements = [] + highWaterMark = undefined pausePty = vi.fn() + destroyPty = vi.fn() mockPtySpawn.mockReset() mockPtySpawn.mockReturnValue({ pid: process.pid, onData: vi.fn((callback: (data: string) => void) => { dataCallback = callback }), - onExit: vi.fn(), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => { + exitCallback = callback + }), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), clear: vi.fn(), pause: pausePty, - resume: vi.fn() + resume: vi.fn(), + destroy: destroyPty }) dispatcher = new RelayDispatcher( (data, settle) => { @@ -86,10 +99,24 @@ describe('PtyHandler negotiated source publication', () => { heldResponseSettlements.push(settle) return true } + const frame = notification(data) + if (holdDataSettlements && frame?.method === 'pty.data') { + heldDataSettlements.push(settle) + return true + } + if (frame?.method === 'pty.exit') { + // Why: real sockets never settle inside write(); a synchronous settle would re-enter + // the legacy capacity path before the pending exit is retired. + queueMicrotask(() => settle({ ok: true })) + return true + } settle({ ok: true }) return true }, - { supportsWriteCallback: true }, + { + supportsWriteCallback: true, + writableHighWaterMark: () => highWaterMark ?? 0 + }, endpointIdentity ) handler = new PtyHandler(dispatcher) @@ -132,6 +159,133 @@ describe('PtyHandler negotiated source publication', () => { .filter((frame): frame is Notification => frame?.method === 'pty.data') } + function exitFrames(): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + async function cancelSourceDelivery(spawnResult: Record): Promise { + const activation = spawnResult.sourceActivation as Record + dispatcher.feed( + requestFrame(9, 'pty.cancelDelivery', { + id: spawnResult.id, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + ) + await vi.advanceTimersByTimeAsync(0) + } + + function stubPublication(overrides: Partial>): RelayPtySourcePublication { + return { + accepts: () => true, + exitPublicationSettled: () => false, + sealAndPublishExit: () => false, + publish: () => false, + onCreditAvailable: () => {}, + receivingActivation: () => undefined, + waitForPendingSend: async () => true, + activate: () => false, + getDebugSnapshot: () => ({}), + dispose: () => {}, + ...overrides + } as unknown as RelayPtySourcePublication + } + + it('publishes a single legacy exit after the owner cancels its delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + dataCallback!('prompt') + await vi.advanceTimersByTimeAsync(8) + await cancelSourceDelivery(spawnResult) + expect(publication.accepts(String(spawnResult.id))).toBe(false) + + expect(() => exitCallback!({ exitCode: 0 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 0 }) + expect(destroyPty).toHaveBeenCalledOnce() + + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + await vi.advanceTimersByTimeAsync(8) + expect(exitFrames()).toHaveLength(1) + }) + + it('drains buffered legacy output before the exit when capacity returns', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + await cancelSourceDelivery(spawnResult) + holdDataSettlements = true + + dataCallback!('first') + await vi.advanceTimersByTimeAsync(8) + expect(heldDataSettlements).toHaveLength(1) + // Why: shrinking the frame capacity makes the next chunk unpublishable, so it stays queued. + highWaterMark = 64 + dataCallback!('second') + await vi.advanceTimersByTimeAsync(8) + + expect(() => exitCallback!({ exitCode: 3 })).not.toThrow() + expect(exitFrames()).toHaveLength(0) + + highWaterMark = undefined + holdDataSettlements = false + heldDataSettlements[0]({ ok: true }) + await vi.advanceTimersByTimeAsync(8) + + const frames = writes + .map(notification) + .filter( + (frame): frame is Notification => + frame?.method === 'pty.data' || frame?.method === 'pty.exit' + ) + expect(frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data', 'pty.exit']) + expect(frames.map((frame) => frame.params.data)).toEqual(['first', 'second', undefined]) + expect(frames.at(-1)!.params).toMatchObject({ id: spawnResult.id, code: 3 }) + }) + + it('contains a source publication fault and still delivers the legacy exit', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + handler.setSourcePublication( + stubPublication({ + sealAndPublishExit: () => { + throw new Error('ledger delivery vanished') + } + }) + ) + + expect(() => exitCallback!({ exitCode: 7 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 7 }) + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-handler] pty source exit publication failed' + ) + } finally { + stderr.mockRestore() + } + }) + + it('completes the exit from the settled state without re-sealing the delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const sealAndPublishExit = vi.fn(() => true) + handler.setSourcePublication( + stubPublication({ exitPublicationSettled: () => true, sealAndPublishExit }) + ) + + exitCallback!({ exitCode: 0 }) + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + + expect(sealAndPublishExit).not.toHaveBeenCalled() + expect(exitFrames()).toHaveLength(0) + }) + it('fences the first source frame behind immutable spawn and attach activation metadata', async () => { heldResponseId = 2 await spawn({}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index f5275e3fc12..c54e1d13e89 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1030,14 +1030,33 @@ export class PtyHandler { return } if (this.sourcePublication?.accepts(id)) { - if ( - !this.sourcePublication.sealAndPublishExit(exit) || - !this.sourcePublication.exitPublicationSettled(id) - ) { + try { + // Why: after the exit settlement, re-entering sealAndPublishExit would pump a closed + // ledger delivery; the settled state alone decides completion. + if (this.sourcePublication.exitPublicationSettled(id)) { + this.pendingExitByPty.delete(id) + return + } + if (!this.sourcePublication.sealAndPublishExit(exit)) { + return + } + if ( + this.sourcePublication.accepts(id) && + !this.sourcePublication.exitPublicationSettled(id) + ) { + return + } + this.pendingExitByPty.delete(id) return + } catch (err) { + // Why: a source-publication fault must never escape onExit — it reaches + // uncaughtException and kills the whole relay daemon. Fall back to the legacy exit. + process.stderr.write( + `[pty-handler] pty source exit publication failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } - this.pendingExitByPty.delete(id) - return } const published = this.dispatcher.tryNotifyPtyExit ? this.dispatcher.tryNotifyPtyExit(exit) diff --git a/src/relay/pty-source-credit-availability-notice.ts b/src/relay/pty-source-credit-availability-notice.ts new file mode 100644 index 00000000000..392bab9c775 --- /dev/null +++ b/src/relay/pty-source-credit-availability-notice.ts @@ -0,0 +1,19 @@ +/** + * Guarded fan-out of "this delivery released credit" to the publication layer. + */ +export function notifyPtySourceCreditAvailable( + notify: ((id: string) => void) | undefined, + id: string +): void { + try { + notify?.(id) + } catch (err) { + // Why: this fires from request handlers, detach paths, and a bare grace setTimeout; a + // downstream throw in the timer would reach uncaughtException and kill the daemon. + process.stderr.write( + `[pty-source-credit] credit-available notification failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) + } +} diff --git a/src/relay/pty-source-credit-ledger.test.ts b/src/relay/pty-source-credit-ledger.test.ts index e34ff5b10e0..37ad35a8f1e 100644 --- a/src/relay/pty-source-credit-ledger.test.ts +++ b/src/relay/pty-source-credit-ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' +import { CLOSED_DELIVERY_TOMBSTONE_LIMIT } from './pty-source-credit-record' function identity( deliveryToken = 'token-1', @@ -501,6 +502,40 @@ describe('RelayPtySourceCreditLedger', () => { expect(ledger.snapshot(owners.at(-1)!)).toMatchObject({ state: 'closed' }) }) + it('probes live, closed, cancelled and evicted deliveries without throwing', () => { + const ledger = new RelayPtySourceCreditLedger() + const live = identity('token-live', { id: 'pty-live' }) + ledger.open(live, 8) + expect(ledger.snapshotIfKnown(live)).toMatchObject({ state: 'active', exitPublished: false }) + + const completed = identity('token-completed', { id: 'pty-completed' }) + ledger.open(completed, 8) + ledger.seal(completed) + ledger.settleExitPublication(completed, { ok: true }) + expect(ledger.snapshotIfKnown(completed)).toMatchObject({ + state: 'closed', + exitPublished: true + }) + + const canceled = identity('token-canceled', { id: 'pty-canceled' }) + ledger.open(canceled, 8) + ledger.cancel(canceled, 'client-request') + expect(ledger.snapshotIfKnown(canceled)).toMatchObject({ + state: 'closed', + exitPublished: false + }) + + expect(ledger.snapshotIfKnown(identity('token-unknown'))).toBeNull() + expect(() => ledger.snapshot(identity('token-unknown'))).toThrow('stale') + + for (let index = 0; index < CLOSED_DELIVERY_TOMBSTONE_LIMIT; index++) { + const evicting = identity(`token-evicting-${index}`, { id: `pty-evicting-${index}` }) + ledger.open(evicting, 8) + ledger.cancel(evicting, 'test') + } + expect(ledger.snapshotIfKnown(canceled)).toBeNull() + }) + it('rejects reopening a recently closed one-use token', () => { const ledger = new RelayPtySourceCreditLedger() const owner = identity() diff --git a/src/relay/pty-source-credit-ledger.ts b/src/relay/pty-source-credit-ledger.ts index 0a697ea30b0..be628672c91 100644 --- a/src/relay/pty-source-credit-ledger.ts +++ b/src/relay/pty-source-credit-ledger.ts @@ -25,6 +25,7 @@ import { createDeliveryCancellation, createDeliveryRecord, createReplacementDeliveryRecord, + matchingDeliverySnapshot, MAX_SOURCE_SPAN_DATA_BYTES, ptyOwnerKey, retainedDataBytesTotal, @@ -248,16 +249,18 @@ export class RelayPtySourceCreditLedger { return Object.freeze({ cancellation, recovery: Object.freeze(replacement.spans.slice()) }) } + // Why: callers that can legitimately race a close probe instead of throwing; an evicted + // tombstone reads as null and must be treated as closed. + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return matchingDeliverySnapshot(this.deliveries, this.closedSnapshots, identity) + } + snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - const active = this.deliveries.get(ptySourceDeliveryKey(identity)) - if (active && samePtySourceDelivery(active.identity, identity)) { - return snapshotDeliveryRecord(active) + const snapshot = this.snapshotIfKnown(identity) + if (!snapshot) { + throw new Error('Unknown or stale PTY source delivery') } - const closed = this.closedSnapshots.get(ptySourceDeliveryKey(identity)) - if (closed && samePtySourceDelivery(closed, identity)) { - return closed - } - throw new Error('Unknown or stale PTY source delivery') + return snapshot } retainedSourceSu = (): number => retainedSourceTotal(this.deliveries.values()) diff --git a/src/relay/pty-source-credit-record.ts b/src/relay/pty-source-credit-record.ts index cbdfd497e2d..d2730d25dc7 100644 --- a/src/relay/pty-source-credit-record.ts +++ b/src/relay/pty-source-credit-record.ts @@ -5,7 +5,11 @@ import type { PtySourceSpan, PtySourceTransform } from '../shared/pty-source-credit-contract' -import { ptySourceSpanIsSplittable } from '../shared/pty-source-credit-contract' +import { + ptySourceDeliveryKey, + ptySourceSpanIsSplittable, + samePtySourceDelivery +} from '../shared/pty-source-credit-contract' import { assertNonNegativeSafeInteger, assertPositiveSafeInteger, @@ -169,6 +173,21 @@ export function snapshotDeliveryRecord(record: DeliveryRecord): PtySourceDeliver }) } +// Why: identity equality is re-checked because a delivery key outlives the token that made it. +export function matchingDeliverySnapshot( + deliveries: ReadonlyMap, + closedSnapshots: ReadonlyMap, + identity: PtySourceDeliveryIdentity +): PtySourceDeliverySnapshot | null { + const key = ptySourceDeliveryKey(identity) + const active = deliveries.get(key) + if (active && samePtySourceDelivery(active.identity, identity)) { + return snapshotDeliveryRecord(active) + } + const closed = closedSnapshots.get(key) + return closed && samePtySourceDelivery(closed, identity) ? closed : null +} + export function retainedSourceTotal(records: Iterable): number { let total = 0 for (const record of records) { diff --git a/src/relay/relay-pty-source-cancellation-exit.test.ts b/src/relay/relay-pty-source-cancellation-exit.test.ts new file mode 100644 index 00000000000..e59b909802b --- /dev/null +++ b/src/relay/relay-pty-source-cancellation-exit.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PTY_CONSUMER_OWNER_GRACE_MS } from '../shared/pty-consumer-session' +import { + RelayDispatcher, + type RelayClientSessionIdentity, + type SinkWriteSettlement +} from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +type Notification = { method: string; params: Record } + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function decode(buffer: Buffer): Record | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +function notification(buffer: Buffer): Notification | null { + const message = decode(buffer) + return typeof message?.method === 'string' && message.id === undefined + ? (message as Notification) + : null +} + +function responseResult(buffer: Buffer): Record | null { + const message = decode(buffer) + return !message || message.id === undefined + ? null + : ((message.result as Record) ?? null) +} + +async function flushRequests(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('RelayPtySourcePublication cancellation and exit', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + async function createHarness(windowSu = 8, holdExitSettlement = false) { + const writes: Buffer[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + const frame = notification(data) + if (frame?.method === 'pty.exit') { + exitSettlements.push(onSettled) + if (holdExitSettlement) { + return true + } + } + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + let publication: RelayPtySourcePublication + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: windowSu } } + }) + ) + await flushRequests() + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + return { adapter, publication, exitSettlements, capacityIds, writes } + } + + function cancelDeliveryFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string } + ): Buffer { + return requestFrame(requestId, 'pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + } + + function exitFrames(writes: Buffer[]): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + function ackFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string }, + creditedEndSu: number + ): Buffer { + return encodeJsonRpcFrame( + { + jsonrpc: '2.0', + method: 'pty.ackData', + params: { + acknowledgements: [ + { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken, + creditedEndSu + } + ] + } + }, + requestId, + 0 + ) + } + + it('retires the record when a client cancels, so the exit never seals a dead ledger', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + }) + + it('retires the record when the reconnect grace expires', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + try { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + + dispatcher!.invalidateClient() + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + } finally { + vi.useRealTimers() + } + }) + + it('opens a fresh delivery when the same client re-attaches after a cancel', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + harness.publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(exitFrames(harness.writes)).toHaveLength(1) + }) + + it('survives a cancel while the credit-mode exit frame is still in flight', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.exitSettlements).toHaveLength(1) + + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + expect( + harness.writes.map(responseResult).filter((result) => result?.canceled === true) + ).toHaveLength(1) + // Why: A' keeps the record while the frame is in flight so no duplicate legacy exit escapes. + expect(harness.publication.accepts('pty-1')).toBe(true) + + expect(() => harness.exitSettlements[0]({ ok: true })).not.toThrow() + + expect(exitFrames(harness.writes)).toHaveLength(1) + expect(harness.capacityIds).toContain('pty-1') + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + }) + + it('retires the record after the in-flight exit frame fails on a canceled delivery', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + const capacityBefore = harness.capacityIds.length + + expect(() => + harness.exitSettlements[0]({ ok: false, error: new Error('socket write failed') }) + ).not.toThrow() + // Why: the delivery is gone, so no credit event will arrive — D's widened finally must retry. + expect(harness.capacityIds.length).toBeGreaterThan(capacityBefore) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.publication.getDebugSnapshot()).toMatchObject({ + exitCommitted: 0, + exitRolledBack: 1 + }) + }) + + it('publishes exactly one exit for a healthy completion even when the retry re-runs', async () => { + const harness = await createHarness() + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + expect(exitFrames(harness.writes)).toHaveLength(1) + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.test.ts b/src/relay/relay-pty-source-exit-publication.test.ts new file mode 100644 index 00000000000..619c7da8829 --- /dev/null +++ b/src/relay/relay-pty-source-exit-publication.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' +import type { + RelayPtySourceDeliveryRecord, + RelayPtySourcePublicationCounters, + RelayPtySourceSendScheduler +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +const params = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function closedSnapshot( + overrides: Partial = {} +): PtySourceDeliverySnapshot { + return Object.freeze({ + ...identity, + state: 'closed', + windowSu: 8, + receivedEndSu: 0, + sentEndSu: 0, + creditedEndSu: 0, + exitPublished: false, + generationClosed: false, + ...overrides + }) +} + +function createScenario( + initialProbe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord, + notifyAccepted = true +) { + const deliveries = new Map([['pty-1', record]]) + let probe = initialProbe + const setProbe = (next: PtySourceDeliverySnapshot | null): void => { + probe = next + } + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + if (!probe) { + throw new Error('Unknown or stale PTY source delivery') + } + return probe + }), + sealDelivery: vi.fn(), + settleExitPublication: vi.fn(), + deliveryMode: vi.fn(() => 'source-owner' as const) + } + const dispatcher = { + tryNotifyPtyExit: vi.fn(() => notifyAccepted), + tryNotifyPtyExitToMatchingClients: vi.fn( + (_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted + ), + projectPtyExitToMatchingClients: vi.fn(() => true), + tryNotifyPtyExitToClient: vi.fn(() => true) + } + const sender = { pump: vi.fn() } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const run = (): boolean => + sealAndPublishPtySourceExit({ + params, + record, + deliveries, + dispatcher: dispatcher as unknown as RelayDispatcher, + session: session as unknown as SshPtyConsumerSessionAdapter, + sender: sender as unknown as RelayPtySourceSendScheduler, + counters, + onCapacity: (id) => capacityIds.push(id) + }) + return { deliveries, session, dispatcher, sender, counters, capacityIds, run, setProbe } +} + +describe('sealAndPublishPtySourceExit closed-delivery guard', () => { + it.each([ + ['an unknown probe', null], + ['a closed probe', closedSnapshot()], + ['a closing probe', closedSnapshot({ state: 'closing' })] + ])('broadcasts a legacy exit and retires the record for %s', (_label, probe) => { + const record = deliveryRecord() + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).toHaveBeenCalledWith(params) + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.session.sealDelivery).not.toHaveBeenCalled() + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('re-targets only source-owner clients once a legacy broadcast already landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0] + expect(matches(1)).toBe(true) + scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never) + expect(matches(1)).toBe(false) + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('keeps the record retryable when the legacy notify is refused', () => { + const record = deliveryRecord() + const scenario = createScenario(closedSnapshot(), record, false) + + expect(scenario.run()).toBe(false) + + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) + + it.each([ + ['the tombstone retains exitPublished', closedSnapshot({ exitPublished: true }), 'idle'], + ['the tombstone was evicted', null, 'published'] + ])('retires a healthily completed delivery silently when %s', (_label, probe, exitState) => { + const record = deliveryRecord({ + sourceExitState: exitState as RelayPtySourceDeliveryRecord['sourceExitState'] + }) + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('defers to the in-flight exit frame settlement', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(false) + + expect(scenario.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) +}) + +describe('sealAndPublishPtySourceExit settlement closure', () => { + function createInFlightScenario(settlementProbe: PtySourceDeliverySnapshot | null) { + const record = deliveryRecord({ sealed: true, legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) + let settle: ((result: { ok: true } | { ok: false; error: Error }) => void) | undefined + scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation( + (...args: unknown[]): boolean => { + settle = args[2] as typeof settle + return true + } + ) + expect(scenario.run()).toBe(true) + scenario.setProbe(settlementProbe) + return { ...scenario, record, settle: settle! } + } + + it('skips the ledger settle and still resumes capacity when the delivery vanished', () => { + const scenario = createInFlightScenario(null) + + expect(() => scenario.settle({ ok: true })).not.toThrow() + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('published') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it('resumes capacity on a failed settlement once the delivery is gone', () => { + const scenario = createInFlightScenario(closedSnapshot()) + + scenario.settle({ ok: false, error: new Error('socket write failed') }) + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('idle') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it.each([ + ['committed', { ok: true } as const], + ['rolled back', { ok: false, error: new Error('socket write failed') } as const] + ])('logs, contains and resumes after a %s ledger settlement fault', (_label, result) => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const scenario = createInFlightScenario(closedSnapshot({ state: 'sealed-unsettled' })) + scenario.session.settleExitPublication.mockImplementation(() => { + throw new Error('PTY source delivery is not sealed') + }) + + expect(() => scenario.settle(result)).not.toThrow() + + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-source-exit] exit settlement failed for pty-1' + ) + expect(scenario.capacityIds).toEqual(['pty-1']) + } finally { + stderr.mockRestore() + } + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.ts b/src/relay/relay-pty-source-exit-publication.ts index 76990942fc3..deafb51fc5b 100644 --- a/src/relay/relay-pty-source-exit-publication.ts +++ b/src/relay/relay-pty-source-exit-publication.ts @@ -27,6 +27,32 @@ export function sealAndPublishPtySourceExit(options: { } return published } + if (record.sourceExitState === 'pending') { + // Why: an exit frame is in flight; its settlement drives the next step. + return false + } + const probe = session.sourceDeliverySnapshotIfKnown(record.identity) + if (!probe || probe.state === 'closed' || probe.state === 'closing') { + if (probe?.exitPublished === true || record.sourceExitState === 'published') { + // Why: the delivery completed healthily — the owner already has the credit-mode exit. + if (deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return true + } + // Why: the delivery was canceled out from under the record; never touch the sealed + // ledger — the exit flows as a legacy broadcast instead. + const published = record.legacyExitAccepted + ? dispatcher.tryNotifyPtyExitToMatchingClients( + (clientId) => session.deliveryMode(clientId) === 'source-owner', + params + ) + : dispatcher.tryNotifyPtyExit(params) + if (published && deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return published + } if (!record.sealed) { session.sealDelivery(record.identity) record.sealed = true @@ -50,17 +76,33 @@ export function sealAndPublishPtySourceExit(options: { } record.sourceExitState = 'pending' const settle = onceSinkSettlement((result) => { + if (result.ok) { + record.sourceExitState = 'published' + counters.exitCommitted++ + } else { + record.sourceExitState = 'idle' + counters.exitRolledBack++ + } + let deliveryGone = false + let settlementFailed = false try { - session.settleExitPublication(record.identity, result) - if (result.ok) { - record.sourceExitState = 'published' - counters.exitCommitted++ - } else { - record.sourceExitState = 'idle' - counters.exitRolledBack++ + // Why: a client cancel or rotation can close the delivery while this frame is in + // flight; settling a closed ledger entry throws out of a bare socket write/drain + // callback (dispatcher-client-writer releaseEntry) straight into uncaughtException. + deliveryGone = + session.sourceDeliverySnapshotIfKnown(record.identity)?.state !== 'sealed-unsettled' + if (!deliveryGone) { + session.settleExitPublication(record.identity, result) } + } catch (err) { + settlementFailed = true + process.stderr.write( + `[pty-source-exit] exit settlement failed for ${params.id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } finally { - if (result.ok) { + if (result.ok || deliveryGone || settlementFailed) { onCapacity(params.id) } } diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts index 6beefefa6cd..bbc57d1c643 100644 --- a/src/relay/relay-pty-source-output.ts +++ b/src/relay/relay-pty-source-output.ts @@ -1,3 +1,9 @@ +import { randomUUID } from 'node:crypto' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + export type RelayPtySourceOutput = { data: string rawLength?: number @@ -6,3 +12,60 @@ export type RelayPtySourceOutput = { sourceAccepted?: boolean sourceSpanId?: string } + +// Why: an evicted tombstone probes null, so an unknown delivery counts as closed. +export function ptySourceDeliveryClosed( + session: SshPtyConsumerSessionAdapter, + identity: PtySourceDeliveryIdentity +): boolean { + const state = session.sourceDeliverySnapshotIfKnown(identity)?.state + return !state || state === 'closed' || state === 'closing' +} + +export function appendPtySourceOutput( + session: SshPtyConsumerSessionAdapter, + record: RelayPtySourceDeliveryRecord, + output: RelayPtySourceOutput +): boolean { + const rawLength = output.rawLength ?? output.data.length + output.sourceSpanId ??= randomUUID() + try { + session.appendSource(record.identity, { + spanId: output.sourceSpanId, + data: output.data, + displayStart: record.displayEnd, + displayEnd: record.displayEnd + output.data.length, + splittable: output.transformed !== true, + transform: { + transformed: output.transformed === true, + rawLengthSu: rawLength, + scalarSafe: output.transformed !== true + } + }) + } catch { + return false + } + output.sourceAccepted = true + record.displayEnd += output.data.length + return true +} + +export function projectPtySourceOutputToLegacy( + dispatcher: RelayDispatcher, + session: SshPtyConsumerSessionAdapter, + id: string, + output: RelayPtySourceOutput, + interactive: boolean +): boolean { + return dispatcher.projectPtyDataToMatchingClients( + (clientId) => session.deliveryMode(clientId) !== 'source-owner', + { + id, + data: output.data, + ...(output.seq === undefined ? {} : { seq: output.seq }), + ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), + ...(output.transformed ? { transformed: true } : {}) + }, + { interactive } + ) +} diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts index 3ce4526efa6..1d30e4e8540 100644 --- a/src/relay/relay-pty-source-publication.test.ts +++ b/src/relay/relay-pty-source-publication.test.ts @@ -19,7 +19,9 @@ function requestFrame(id: number, method: string, params: Record } | null { +type Notification = { method: string; params: Record } + +function notification(buffer: Buffer): Notification | null { if (buffer[0] !== MessageType.Regular) { return null } @@ -52,17 +54,23 @@ describe('RelayPtySourcePublication', () => { async function createHarness( windowSu = 4, settleSourceImmediately = true, - highWaterMark?: number + highWaterMark?: number, + holdExitSettlement = false ) { const writes: Buffer[] = [] const sourceSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] dispatcher = new RelayDispatcher( (data, onSettled) => { writes.push(Buffer.from(data)) const frame = notification(data) if (frame?.method === 'pty.data' || frame?.method === 'pty.exit') { sourceSettlements.push(onSettled) - if (settleSourceImmediately) { + if (frame.method === 'pty.exit') { + exitSettlements.push(onSettled) + } + if (settleSourceImmediately && !(holdExitSettlement && frame.method === 'pty.exit')) { onSettled({ ok: true }) } } else { @@ -85,7 +93,7 @@ describe('RelayPtySourcePublication', () => { const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => publication.onCreditAvailable(id) ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) dispatcher.feed( requestFrame(1, 'pty.openClient', { protocolVersion: 1, @@ -105,7 +113,7 @@ describe('RelayPtySourcePublication', () => { }) ).toBe('opened') activationSettlements[0]({ ok: true }) - return { adapter, publication, sourceSettlements, writes } + return { adapter, publication, sourceSettlements, exitSettlements, capacityIds, writes } } it('commits only from writer settlement and resumes exactly after cumulative ACK', async () => { diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index 10ef73b0ce6..b46a65c72e3 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryRequest, @@ -19,7 +18,12 @@ import { } from './relay-pty-source-send-scheduler' import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayPtySourceOutput } from './relay-pty-source-output' +import { + appendPtySourceOutput, + projectPtySourceOutputToLegacy, + ptySourceDeliveryClosed, + type RelayPtySourceOutput +} from './relay-pty-source-output' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' export class RelayPtySourcePublication { @@ -61,7 +65,7 @@ export class RelayPtySourcePublication { return false } const mode = this.session.deliveryMode(context.clientId) - const current = this.deliveries.get(id) + let current = this.deliveries.get(id) if (mode === 'subscriber') { this.sender.releaseRotationFence(current) return false @@ -69,11 +73,24 @@ export class RelayPtySourcePublication { if (mode === 'legacy-owner') { if (current) { this.session.cancelDelivery(current.identity, 'source-credit-disabled') + this.sender.wakeSendWaiters(current) this.deliveries.delete(id) this.onCapacity(id) } return false } + if ( + current?.clientId === context.clientId && + !current.restoreRequired && + current.sourceExitState !== 'pending' && + this.deliveryClosedUnderRecord(current) + ) { + // Why: a canceled delivery can never resume as 'existing'; retire it so re-attach opens fresh. + this.sender.wakeSendWaiters(current) + this.deliveries.delete(id) + this.onCapacity(id) + current = undefined + } if (current?.clientId === context.clientId) { this.sender.releaseRotationFence(current) if (current.activating && current.activationRecoveryRequest) { @@ -195,42 +212,21 @@ export class RelayPtySourcePublication { ) { return false } - if (!output.sourceAccepted) { - const rawLength = output.rawLength ?? output.data.length - output.sourceSpanId ??= randomUUID() - try { - this.session.appendSource(record.identity, { - spanId: output.sourceSpanId, - data: output.data, - displayStart: record.displayEnd, - displayEnd: record.displayEnd + output.data.length, - splittable: output.transformed !== true, - transform: { - transformed: output.transformed === true, - rawLengthSu: rawLength, - scalarSafe: output.transformed !== true - } - }) - } catch { - this.counters.appendDenied++ + if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) { + if (this.deliveryClosedUnderRecord(record)) { + this.sender.wakeSendWaiters(record) + this.deliveries.delete(id) + // Why: deferred — publish() can run inside flushPendingOutput's captured-queue drain, + // where pendingOutputByPty is transiently empty; a synchronous capacity callback would + // publish pty.exit ahead of still-buffered output. By microtask time the failed chunk + // has been re-queued (flushPtyOutput re-sets the queue synchronously on failure). + queueMicrotask(() => this.onCapacity(id)) return false } - output.sourceAccepted = true - record.displayEnd += output.data.length + this.counters.appendDenied++ + return false } - if ( - !this.dispatcher.projectPtyDataToMatchingClients( - (clientId) => this.session.deliveryMode(clientId) !== 'source-owner', - { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }, - { interactive } - ) - ) { + if (!projectPtySourceOutputToLegacy(this.dispatcher, this.session, id, output, interactive)) { return false } this.sender.pump(record) @@ -269,6 +265,10 @@ export class RelayPtySourcePublication { dispose = (): void => this.sender.dispose() + private deliveryClosedUnderRecord(record: RelayPtySourceDeliveryRecord): boolean { + return ptySourceDeliveryClosed(this.session, record.identity) + } + private registerActivationSettlement( id: string, record: RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-send-scheduler.test.ts b/src/relay/relay-pty-source-send-scheduler.test.ts new file mode 100644 index 00000000000..7c130be7045 --- /dev/null +++ b/src/relay/relay-pty-source-send-scheduler.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { + RelayPtySourceSendScheduler, + type RelayPtySourceDeliveryRecord, + type RelayPtySourcePublicationCounters +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function createScheduler( + probe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord +) { + const deliveries = new Map([['pty-1', record]]) + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + throw new Error('Unknown or stale PTY source delivery') + }), + reserveSourceSend: vi.fn(() => null) + } + const dispatcher = { + onLegacyPtyCapacity: vi.fn(() => () => {}), + producerDataBudget: vi.fn(() => 4096), + tryNotifyPtyDataToClient: vi.fn(() => true) + } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const scheduler = new RelayPtySourceSendScheduler( + dispatcher as unknown as RelayDispatcher, + session as unknown as SshPtyConsumerSessionAdapter, + deliveries, + counters, + (id) => capacityIds.push(id) + ) + return { scheduler, deliveries, session, capacityIds } +} + +describe('RelayPtySourceSendScheduler close handling', () => { + it('leaves an in-flight exit frame untouched on a credit event', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual([]) + }) + + it('prunes a record whose tombstone was already evicted', () => { + const waiter = vi.fn() + const record = deliveryRecord({ sendWaiters: new Set([waiter]) }) + const harness = createScheduler(null, record) + + expect(() => harness.scheduler.onCreditAvailable('pty-1')).not.toThrow() + + expect(waiter).toHaveBeenCalledOnce() + expect(record.sendWaiters.size).toBe(0) + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('preserves a closed record after the legacy exit projection landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('prunes a healthily published exit after its final credit closes the delivery', () => { + const record = deliveryRecord({ + legacyExitAccepted: true, + sourceExitState: 'published' + }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('skips unknown deliveries in the debug snapshot instead of throwing', () => { + const harness = createScheduler(null, deliveryRecord({ sealed: true })) + + expect(harness.scheduler.getDebugSnapshot()).toMatchObject({ + active: 0, + activating: 0, + sealedUnsettled: 0, + outstandingSourceUnits: 0 + }) + }) +}) diff --git a/src/relay/relay-pty-source-send-scheduler.ts b/src/relay/relay-pty-source-send-scheduler.ts index 59402f5e5ba..bbec1a824d3 100644 --- a/src/relay/relay-pty-source-send-scheduler.ts +++ b/src/relay/relay-pty-source-send-scheduler.ts @@ -1,4 +1,7 @@ -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryCheckpoint } from '../shared/pty-source-recovery-contract' import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' @@ -100,7 +103,23 @@ export class RelayPtySourceSendScheduler { if (!record || record.restoreRequired) { return } - if (this.pruneClosed(id, record)) { + if (record.sourceExitState === 'pending') { + // Why: the credit-mode exit frame is in flight; pruning now diverts publishPendingExit + // into a duplicate legacy pty.exit, and pumping a closed delivery throws. The exit + // settlement (which fires onCapacity) resumes progress. + return + } + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if ( + record.sourceExitState === 'idle' && + record.legacyExitAccepted && + (!snapshot || snapshot.state === 'closed' || snapshot.state === 'closing') + ) { + // Why: preserve partial exit progress so the retry targets only the source owner. + this.onCapacity(id) + return + } + if (this.pruneClosed(id, record, snapshot)) { this.onCapacity(id) return } @@ -114,7 +133,10 @@ export class RelayPtySourceSendScheduler { let sealedUnsettled = 0 let outstandingSourceUnits = 0 for (const record of this.deliveries.values()) { - const snapshot = this.session.sourceDeliverySnapshot(record.identity) + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if (!snapshot) { + continue + } outstandingSourceUnits += snapshot.sentEndSu - snapshot.creditedEndSu if (record.activating) { activating++ @@ -262,11 +284,20 @@ export class RelayPtySourceSendScheduler { }) } - pruneClosed(id: string, record: RelayPtySourceDeliveryRecord): boolean { - if (this.session.sourceDeliverySnapshot(record.identity).state !== 'closed') { + pruneClosed( + id: string, + record: RelayPtySourceDeliveryRecord, + snapshot: PtySourceDeliverySnapshot | null = this.session.sourceDeliverySnapshotIfKnown( + record.identity + ) + ): boolean { + // Why: an evicted tombstone probes null and must count as closed — this runs from paths + // (credit callbacks, write settlements) where a throw escapes every caller's try/catch. + if (snapshot && snapshot.state !== 'closed') { return false } if (this.deliveries.get(id) === record) { + this.wakeSendWaiters(record) this.deliveries.delete(id) } return true diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index c5bedb37053..1617fdb09dd 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -217,6 +217,12 @@ export class SshPtyConsumerSessionAdapter { return this.sourceCredit.snapshot(identity) } + sourceDeliverySnapshotIfKnown( + identity: PtySourceDeliveryIdentity + ): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + cancelDelivery(identity: PtySourceDeliveryIdentity, reason: string): void { this.clearPausedIdentity(identity) this.sourceCredit.cancelIdentity(identity, reason) diff --git a/src/relay/ssh-pty-source-credit-adapter.test.ts b/src/relay/ssh-pty-source-credit-adapter.test.ts index f738395a154..915a7683f09 100644 --- a/src/relay/ssh-pty-source-credit-adapter.test.ts +++ b/src/relay/ssh-pty-source-credit-adapter.test.ts @@ -114,6 +114,90 @@ describe('SshPtySourceCreditAdapter cleanup', () => { expect(() => adapter.open(grant, 'pty-late', 'incarnation-late')).toThrow('disposed') }) + it('notifies credit availability on every adapter-side cancellation but not on rotate', () => { + vi.useFakeTimers() + const onCreditAvailable = vi.fn() + const adapter = new SshPtySourceCreditAdapter(undefined, onCreditAvailable) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-cancel', 'incarnation-1')! + + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel']]) + + const expiring = adapter.open(grant, 'pty-grace', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace']]) + expect(adapter.snapshot(expiring).state).toBe('closed') + + const detaching = adapter.open(ownerGrant(3, 3), 'pty-detach', 'incarnation-3')! + adapter.retainOrCloseOnDetach( + Object.freeze({ + protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + serverBuildId: 'build-a', + clientGeneration: 3, + role: 'subscriber' + }) + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace'], ['pty-detach']]) + expect(adapter.snapshot(detaching).state).toBe('closed') + + const rotating = adapter.open(ownerGrant(4, 4), 'pty-rotate', 'incarnation-4')! + adapter.rotate(rotating, ownerGrant(5, 5), 0) + const publicationOwned = adapter.open(ownerGrant(6, 6), 'pty-publication', 'incarnation-5')! + adapter.cancelIdentity(publicationOwned, 'publication-owned') + expect(onCreditAvailable).toHaveBeenCalledTimes(3) + expect(adapter.snapshot(publicationOwned).state).toBe('closed') + }) + + it('swallows a throwing credit-available callback in cancel and in the grace timer', () => { + vi.useFakeTimers() + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const adapter = new SshPtySourceCreditAdapter(undefined, () => { + throw new Error('publication faulted') + }) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-1', 'incarnation-1')! + + expect(() => + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + ).not.toThrow() + + const expiring = adapter.open(grant, 'pty-2', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + expect(() => vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS)).not.toThrow() + + expect(adapter.snapshot(expiring).state).toBe('closed') + expect(stderr.mock.calls.map(([line]) => String(line))).toEqual([ + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-1' + ), + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-2' + ) + ]) + } finally { + stderr.mockRestore() + } + }) + it('returns the same bounded proof for duplicate token cancellation', () => { const adapter = new SshPtySourceCreditAdapter() const grant = ownerGrant(1, 1) diff --git a/src/relay/ssh-pty-source-credit-adapter.ts b/src/relay/ssh-pty-source-credit-adapter.ts index d551dec8142..e03c647c912 100644 --- a/src/relay/ssh-pty-source-credit-adapter.ts +++ b/src/relay/ssh-pty-source-credit-adapter.ts @@ -15,6 +15,7 @@ import { ptySourceCancellationResult, RecentPtySourceCancellationIndex } from './pty-source-cancellation-index' +import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' import { ownedPtySourceDelivery } from './pty-source-delivery-ownership' import { RelayPtySourceCreditLedger, @@ -141,6 +142,10 @@ export class SshPtySourceCreditAdapter { return this.sourceCredit.snapshot(identity) } + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + acknowledge( params: Record, grant: Readonly | null @@ -205,6 +210,9 @@ export class SshPtySourceCreditAdapter { this.identityByToken.delete(token) this.recentCancellations.remember(proof) this.clearGraceWhenSettled(identity.ownerGeneration) + // Why: the publication must retire its record the moment the delivery closes, or the next + // exit seals a dead ledger entry. + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) return ptySourceCancellationResult(proof) } @@ -247,9 +255,7 @@ export class SshPtySourceCreditAdapter { return } this.disposed = true - for (const timer of this.graceTimers.values()) { - clearTimeout(timer) - } + this.graceTimers.forEach((timer) => clearTimeout(timer)) this.graceTimers.clear() this.sourceCredit.closeGeneration(this.relayProviderGeneration) this.identityByToken.clear() @@ -262,6 +268,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'client-detached') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } } @@ -274,6 +281,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'reconnect-grace-expired') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } }, PTY_CONSUMER_OWNER_GRACE_MS) timer.unref?.() @@ -314,10 +322,7 @@ export class SshPtySourceCreditAdapter { } private clearGraceTimer(ownerGeneration: number): void { - const timer = this.graceTimers.get(ownerGeneration) - if (timer) { - clearTimeout(timer) - this.graceTimers.delete(ownerGeneration) - } + clearTimeout(this.graceTimers.get(ownerGeneration)) + this.graceTimers.delete(ownerGeneration) } } From 9a644c75c5d2a9b04854e336a65033a596e78625 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:51:11 -0700 Subject: [PATCH 032/123] test(e2e): stabilize browser split, terminal pressure, and headless startup (#11628) * test(e2e): focus collapsed browser split address bar * test(e2e): stabilize terminal pressure revisit * test(e2e): retry headless runtime startup --- .../artificial-opencode-pane-interactions.ts | 82 ++++++++++++++++++- ...cial-opencode-revisit-pressure-scenario.ts | 4 + .../artificial-opencode-terminal-load.spec.ts | 3 +- tests/e2e/browser-split-find-shortcut.spec.ts | 23 ++++-- .../headless-serve-desktop-activation.spec.ts | 21 ++++- 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/tests/e2e/artificial-opencode-pane-interactions.ts b/tests/e2e/artificial-opencode-pane-interactions.ts index f93c3a0788d..a8998c7b994 100644 --- a/tests/e2e/artificial-opencode-pane-interactions.ts +++ b/tests/e2e/artificial-opencode-pane-interactions.ts @@ -1,11 +1,13 @@ import type { Page } from '@stablyai/playwright-test' import { expect } from './helpers/orca-app' -import { ensureTerminalVisible } from './helpers/store' +import { ensureTerminalVisible, getActiveWorktreeId, switchToWorktree } from './helpers/store' import { getTerminalContent, + readPaneIdentitySnapshot, splitActiveTerminalPane, + UUID_RE, waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + type PaneIdentitySnapshot } from './helpers/terminal' export type TerminalLoadPane = { @@ -52,16 +54,47 @@ export async function focusPane(page: Page, paneKey: string): Promise { ) } +export async function waitForTerminalPtyVisible( + page: Page, + ptyId: string, + timeoutMs = 10_000 +): Promise { + await expect + .poll( + () => + page.evaluate((targetPtyId) => { + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = manager + .getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === targetPtyId) + if (pane) { + return pane.container.isConnected && pane.container.getClientRects().length > 0 + } + } + return false + }, ptyId), + { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not become visible` + } + ) + .toBe(true) +} + export async function ensureActiveWorktreePaneLoad( page: Page, paneCount: number ): Promise { await ensureTerminalVisible(page) await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) + const worktreeId = await getActiveWorktreeId(page) + if (!worktreeId) { + throw new Error('Active worktree is unavailable for terminal pane load') + } + let snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, 1) while (snapshot.panes.length < paneCount) { await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, snapshot.panes.length + 1) } return snapshot.panes.slice(0, paneCount).map((pane) => ({ paneKey: `${snapshot.tabId}:${pane.leafId}`, @@ -69,6 +102,47 @@ export async function ensureActiveWorktreePaneLoad( })) } +async function waitForActiveWorktreePaneLoad( + page: Page, + worktreeId: string, + paneCount: number +): Promise { + let snapshot: PaneIdentitySnapshot | null = null + await expect + .poll( + async () => { + if ((await getActiveWorktreeId(page)) !== worktreeId) { + // Why: late session reconciliation can clear selection while split PTYs bind. + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + } + snapshot = await readPaneIdentitySnapshot(page) + return Boolean( + snapshot && + snapshot.panes.length === paneCount && + snapshot.panes.every( + (pane) => + UUID_RE.test(pane.leafId) && + pane.stablePaneId === pane.leafId && + pane.datasetLeafId === pane.leafId && + pane.ptyId !== null && + snapshot?.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) + ) + }, + { + timeout: 15_000, + message: 'Artificial load panes did not settle with stable PTY bindings' + } + ) + .toBe(true) + if (!snapshot) { + throw new Error('Artificial load pane snapshot is unavailable') + } + return snapshot +} + export async function waitForMarkerLatency( page: Page, marker: string, diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index c533a395e22..48643b8d8de 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -17,6 +17,7 @@ import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyVisible } from './artificial-opencode-pane-interactions' type RevisitPressurePane = { paneKey: string; ptyId: string } @@ -161,6 +162,7 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForTerminalPtyVisible(orcaPage, typingPtyId) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -198,6 +200,8 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, firstWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + // Why: hidden PaneManagers persist, so manager readiness alone can race the reveal commit. + await waitForTerminalPtyVisible(orcaPage, revisitPane.ptyId) await deps.focusPane(orcaPage, revisitPane.paneKey) await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 9b937775a84..3e857d3258f 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -146,7 +146,8 @@ const MAX_TIMER_DRIFT_MS = 250 // the real baseline. const MAX_TIMER_DRIFT_UNDER_LOAD_MS = 2_500 const MAX_SCROLL_LATENCY_MS = 150 -const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 +// Why: byte-level peaks vary by drain quantum; the coarse guard matches the main-pressure scenario. +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] diff --git a/tests/e2e/browser-split-find-shortcut.spec.ts b/tests/e2e/browser-split-find-shortcut.spec.ts index a97448ec019..39847bdeac5 100644 --- a/tests/e2e/browser-split-find-shortcut.spec.ts +++ b/tests/e2e/browser-split-find-shortcut.spec.ts @@ -88,6 +88,17 @@ function browserAddressBar(page: Page, browserTabId: string) { ) } +async function focusBrowserAddressBar(page: Page, browserTabId: string): Promise { + const browserOverlay = page.locator(`[data-browser-overlay-tab-id="${browserTabId}"]`) + const addressBar = browserAddressBar(page, browserTabId) + const addressBarForm = browserOverlay.locator( + 'form:has(> [data-orca-browser-address-bar="true"])' + ) + await expect(addressBarForm).toBeVisible() + await addressBarForm.click() + await expect(addressBar).toBeFocused() +} + function browserFindInput(page: Page) { return page.getByPlaceholder('Find in page...') } @@ -200,9 +211,7 @@ test.describe('browser split Find shortcut', () => { await orcaPage.keyboard.press('Escape') await focusBrowserGroup(orcaPage, fixture.browserGroupId) - const browserAddress = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(browserAddress).toBeVisible() - await browserAddress.click() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.keyboard.press(`${modifier}+f`) await expect(browserFindInput(orcaPage)).toBeFocused() await expect(terminalFindInput(orcaPage)).toBeHidden() @@ -243,9 +252,7 @@ test.describe('browser split Find shortcut', () => { const fixture = await createTerminalBrowserSplit(orcaPage) await focusBrowserGroup(orcaPage, fixture.browserGroupId) const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(addressBar).toBeVisible() - await addressBar.click() - await expect(addressBar).toBeFocused() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.evaluate(() => { const store = window.__store @@ -270,9 +277,7 @@ test.describe('browser split Find shortcut', () => { const fixture = await createTerminalBrowserSplit(orcaPage) await focusBrowserGroup(orcaPage, fixture.browserGroupId) const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(addressBar).toBeVisible() - await addressBar.click() - await expect(addressBar).toBeFocused() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.evaluate(() => { const store = window.__store diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 77b85929d70..1a15cfd2897 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -23,6 +23,7 @@ import { } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { RuntimeClient } from '../../src/cli/runtime/client' +import { RuntimeClientError } from '../../src/cli/runtime/types' import type { RuntimeStatus, RuntimeTerminalCreate, @@ -119,10 +120,22 @@ test('promotes the headless owner without replacing its daemon terminal', async const client = new RuntimeClient(userDataDir, 5_000) await expect - .poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, { - timeout: 60_000, - message: 'headless serve never became safely openable' - }) + .poll( + async () => { + try { + return (await client.getCliStatus()).result.app.desktopWindowStatus + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { + return 'starting' + } + throw error + } + }, + { + timeout: 60_000, + message: 'headless serve never became safely openable' + } + ) .toBe('openable') const beforeStatus = await client.call('status.get') From 33b6d4de3e145f06fa366a6cae3e5c98811919c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:52:22 -0700 Subject: [PATCH 033/123] Fix remote terminal recovery and dev HMR instability (#11625) * fix(remote): recover terminal attach and renderer HMR * fix(remote): fence viewport and attach recovery * fix(remote): preserve mirror recovery budget * fix(remote): fence mirror recovery cutoff * fix(remote): stop late mirror attachment * fix(remote): end fatal mirror recovery --- .../terminal-pane/pty-connection.test.ts | 67 ++++ .../terminal-pane/pty-connection.ts | 76 +++- .../remote-runtime-pty-transport.test.ts | 352 ++++++++++++++++++ .../remote-runtime-pty-transport.ts | 138 +++++-- .../src/lib/react-renderer-root.test.ts | 32 ++ src/renderer/src/lib/react-renderer-root.ts | 20 + src/renderer/src/main.tsx | 4 +- src/renderer/src/popout.tsx | 4 +- 8 files changed, 663 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/react-renderer-root.test.ts create mode 100644 src/renderer/src/lib/react-renderer-root.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index db6030d00fd..1d42057d539 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5510,6 +5510,7 @@ describe('connectPanePty', () => { // Once replay completes (guard cleared), real keystrokes flow through. replayingPanesRef.current.delete(1) setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) + transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') expect(transport.sendInput).toHaveBeenCalledWith('a') expect(transport.claimViewport).toHaveBeenCalledTimes(1) @@ -23564,6 +23565,72 @@ describe('connectPanePty', () => { expect(transport.resize).not.toHaveBeenCalled() }) + it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { + let documentFocused = true + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => documentFocused) + } as unknown as Document + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'remote:env-1@@terminal-visible' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) + } as never + const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + transport.claimViewport.mockClear() + + setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) + + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + setFitOverride(ptyId, 'desktop-fit', 132, 42) + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + documentFocused = false + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + documentFocused = true + binding.reassertPtySizeAfterWindowWake() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + binding.dispose() + }) + it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 188fdf11c55..daf09427ca7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -69,7 +69,11 @@ import { type SafeFitContinuationHandle } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' +import { + bindPanePtyId, + getFitOverrideForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' @@ -2249,14 +2253,26 @@ export function connectPanePty( terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) } + let visibleRemoteViewportClaimPtyId: string | null = null + let pendingVisibleRemoteViewportClaim = false const setPanePtyFitBinding = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId + if ( + deps.isVisibleRef.current && + isRemoteRuntimePtyId(ptyId) && + visibleRemoteViewportClaimPtyId !== ptyId + ) { + // Why: the initial fit event consumes this activation arm before later peer ownership changes. + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } // Why: override hydration can arrive before this pane knows its PTY. Once - // data-pty-id is bound, safeFit can park xterm at the held phone grid. + // data-pty-id is bound, safeFit can park xterm at the authoritative grid. if (getFitOverrideForPty(ptyId)) { safeFit(pane) } + claimPendingVisibleRemoteViewport() } let activePanePtyBinding: string | null = null // Why: bind time lets async liveness reconcile ignore a request started @@ -2310,6 +2326,8 @@ export function connectPanePty( // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. bindPanePtyId(pane.id, null, deps.tabId) + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false activePanePtyBinding = null activePanePtyBindingBoundAt = null delete pane.container.dataset.ptyId @@ -3847,6 +3865,52 @@ export function connectPanePty( transport.claimViewport?.(cols, rows) } } + const claimPendingVisibleRemoteViewport = (): void => { + if ( + !pendingVisibleRemoteViewportClaim || + !deps.isVisibleRef.current || + typeof document === 'undefined' || + document.visibilityState === 'hidden' || + typeof document.hasFocus !== 'function' || + !document.hasFocus() + ) { + return + } + claimViewportForUserActivity() + } + const armVisibleRemoteViewportClaim = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false + return + } + if ( + visibleRemoteViewportClaimPtyId !== ptyId || + pendingVisibleRemoteViewportClaim || + getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' + ) { + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } + } + const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { + if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { + return + } + if (event.mode === 'desktop-fit') { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = false + return + } + if (event.mode === 'remote-desktop-fit') { + if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = true + } + claimPendingVisibleRemoteViewport() + } + }) // Why: an unbound transport (detached during a remount/move and never // rebound) silently rejects every keystroke while the PTY stays alive and @@ -8726,15 +8790,22 @@ export function connectPanePty( agentCompletionCoordinator.startProcessTracking() // Why: the hidden-delivery gate must follow every pane visibility flip. syncHiddenRendererPtyDelivery() + if (!deps.isVisibleRef.current) { + pendingVisibleRemoteViewportClaim = false + } }, // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. noteVisibilityResume() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, reassertPtySizeAfterWindowWake() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. @@ -8813,6 +8884,7 @@ export function connectPanePty( // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + unsubscribeRemoteDesktopActivationClaim() cancelHiddenOutputSnapshotScrollRestore() structuralReplayCoordinator.dispose() cancelFreshSpawnFollowReset() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 1a7ca3d7170..8e6f8690267 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -701,6 +701,358 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) + it('retries initial web mirror inventory after a transient runtime close', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const recoveryPhases: string[] = [] + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + cols: 100, + rows: 30, + callbacks: { + onError, + onRecoveryStateChange: (state) => recoveryPhases.push(state.phase) + } + }) + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(activateAttempts).toBe(2) + expect(onError).not.toHaveBeenCalled() + expect(recoveryPhases).toContain('backoff') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + }) + + it('keeps web mirror inventory and subscription failures inside one recovery budget', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + if (request.method === 'session.tabs.list') { + return healthyRuntimeCall?.({ + method: 'session.tabs.activate', + params: { tabId: 'host-tab-1', leafId: 'pane:1' } + }) + } + return healthyRuntimeCall?.(request) + }) + runtimeSubscribe.mockRejectedValue( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(60_000) + + const attemptsAtCutoff = runtimeSubscribe.mock.calls.length + expect(attemptsAtCutoff).toBe(8) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(runtimeSubscribe).toHaveBeenCalledTimes(attemptsAtCutoff) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ends web mirror recovery when a retry returns a fatal inventory error', async () => { + vi.useFakeTimers() + try { + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + throw Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError } + }) + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith('Remote runtime pairing credentials expired.') + expect(transport.getRecoveryState?.().phase).toBe('offline') + const attemptsAfterFatalError = activateAttempts + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(activateAttempts).toBe(attemptsAfterFatalError) + expect(transport.getRecoveryState?.().phase).toBe('offline') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when an in-flight request rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectInFlight: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + return new Promise((_, reject) => { + rejectInFlight = reject + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(activateAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectInFlight( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(activateAttempts).toBe(2) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + runtimeCall.mockImplementation(healthyRuntimeCall!) + expect(transport.retryRecovery?.()).toBe(true) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(latestSubscribePayload().terminal).toBe('terminal-1') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when subscription rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + let rejectSubscription: (error: Error) => void = () => {} + runtimeSubscribe.mockImplementation( + () => + new Promise((_, reject) => { + rejectSubscription = reject + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectSubscription( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not subscribe after mirror metadata resolution crosses the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + let resolveMetadata: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + if (request.method === 'terminal.resolvePane') { + return new Promise((resolve) => { + resolveMetadata = resolve + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + resolveMetadata({ + ok: true, + result: { + terminal: { + handle: 'terminal-1', + tabId: 'host-tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + } + } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(runtimeSubscribe).not.toHaveBeenCalled() + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores stale web mirror inventory failure after a newer connect lifecycle', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectStaleInventory: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return new Promise((_, reject) => { + rejectStaleInventory = reject + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const staleOnError = vi.fn() + const currentOnError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError: staleOnError } + }) + await vi.waitFor(() => expect(activateAttempts).toBe(1)) + await transport.connect({ url: '', callbacks: { onError: currentOnError } }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + + rejectStaleInventory( + Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + ) + for (let index = 0; index < 20; index += 1) { + await Promise.resolve() + } + + expect(staleOnError).not.toHaveBeenCalled() + expect(currentOnError).not.toHaveBeenCalled() + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + transport.destroy?.() + }) + it('resolves a HUB-native SSH PTY wake hint to its runtime terminal handle', async () => { const leafId = '11111111-1111-4111-8111-111111111111' runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7048e324e63..e7df8f18257 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -164,6 +164,7 @@ export function createRemoteRuntimePtyTransport( let resubscribeRequestedRequiresReplacement = false let recoveryRequiresReplacement = false let stopWaitingForPublishedHandle: (() => void) | null = null + let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let attachGeneration = 0 let subscriptionGeneration = 0 @@ -213,6 +214,13 @@ export function createRemoteRuntimePtyTransport( subscriptionGeneration += 1 closeMultiplexedStream() } + if ( + recovery.currentPhase === 'disconnected' || + recovery.currentPhase === 'disposed' || + recovery.currentPhase === 'idle' + ) { + settleHostSessionAttachRetry?.(false) + } emitRecoveryState() }) let lastRecoveryStateKey = '' @@ -228,6 +236,7 @@ export function createRemoteRuntimePtyTransport( let agentSessionRequiresHostAuthorityReplay = false let terminalCreateUnknownOutcomeError: unknown = null let lastConnectOptions: Parameters[0] | null = null + let lastAttachOptions: Parameters[0] | null = null let resolvePaneUnavailable = false let recoveringPaneHandle: string | null = null const adoptExecutionMetadata = (terminal: { @@ -397,7 +406,8 @@ export function createRemoteRuntimePtyTransport( } async function waitForHostSessionHandle( - hostTabId: string + hostTabId: string, + isCurrent: () => boolean ): Promise { if (!worktreeId) { return undefined @@ -425,7 +435,7 @@ export function createRemoteRuntimePtyTransport( } const startedAt = Date.now() - while (!destroyed) { + while (isCurrent()) { const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { return undefined @@ -457,6 +467,63 @@ export function createRemoteRuntimePtyTransport( return undefined } + function waitForHostSessionAttachRetry(recoveryEpoch: number): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (retry: boolean): void => { + if (settled) { + return + } + settled = true + if (settleHostSessionAttachRetry === settle) { + settleHostSessionAttachRetry = null + } + resolve(retry) + } + settleHostSessionAttachRetry?.(false) + settleHostSessionAttachRetry = settle + if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + settle(false) + } + }) + } + + async function waitForHostSessionHandleWithRecovery( + hostTabId: string, + isCurrent: () => boolean + ): Promise { + let recoveryEpoch = recovery.isActive ? recovery.currentEpoch : undefined + while (isCurrent()) { + try { + const hostHandle = await waitForHostSessionHandle(hostTabId, isCurrent) + if (!isCurrent()) { + return undefined + } + if (recoveryEpoch !== undefined) { + if (!recovery.isCurrent(recoveryEpoch)) { + return undefined + } + } + return hostHandle + } catch (error) { + if ( + !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + !isCurrent() + ) { + throw error + } + if (recoveryEpoch !== undefined && !recovery.isCurrent(recoveryEpoch)) { + return undefined + } + recoveryEpoch ??= recovery.begin() + if (!(await waitForHostSessionAttachRetry(recoveryEpoch)) || !isCurrent()) { + return undefined + } + } + } + return undefined + } + async function waitForResubscribeHostSessionHandle( hostTabId: string, previousHandle: string, @@ -523,29 +590,27 @@ export function createRemoteRuntimePtyTransport( async function attachHostSessionMirror( options: { cols?: number; rows?: number }, notifySpawn = true, - expectedAttachGeneration?: number + expectedAttachGeneration?: number, + expectedLifecycleEpoch?: number ): Promise { if (!tabId || !isWebTerminalSurfaceTabId(tabId)) { return undefined } + const isCurrent = (): boolean => + !destroyed && + (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) && + (expectedLifecycleEpoch === undefined || expectedLifecycleEpoch === lifecycleEpoch) const hostTabId = toHostSessionTabId(tabId) - const hostHandle = await waitForHostSessionHandle(hostTabId) - if (hostHandle === undefined || destroyed) { + const hostHandle = await waitForHostSessionHandleWithRecovery(hostTabId, isCurrent) + if (hostHandle === undefined || !isCurrent()) { return undefined } if (hostHandle === null) { storedCallbacks.onError?.('Remote terminal was closed.') return undefined } - if ( - !hostHandle || - destroyed || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { - if ( - !destroyed && - (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) - ) { + if (!hostHandle || !isCurrent()) { + if (isCurrent()) { storedCallbacks.onError?.('Remote terminal was closed.') } return undefined @@ -573,6 +638,9 @@ export function createRemoteRuntimePtyTransport( } } + if (!isCurrent() || recovery.currentPhase === 'disconnected') { + return undefined + } handle = hostHandle remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId) registerShutdownHandlers(remotePtyId) @@ -592,12 +660,7 @@ export function createRemoteRuntimePtyTransport( throw error } } - if ( - destroyed || - !connected || - !remotePtyId || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { + if (!connected || !remotePtyId || !isCurrent()) { return undefined } @@ -1216,6 +1279,9 @@ export function createRemoteRuntimePtyTransport( if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) { return false } + if (recovery.currentPhase === 'disconnected') { + return true + } scheduleResubscribeAfterTransportClose() return true } @@ -1548,6 +1614,7 @@ export function createRemoteRuntimePtyTransport( const connectLifecycleEpoch = ++lifecycleEpoch const createEnvironmentId = currentRuntimeEnvironmentId lastConnectOptions = options + lastAttachOptions = null storedCallbacks = options.callbacks recoveryRequiresReplacement = false terminalEnded = false @@ -1559,7 +1626,7 @@ export function createRemoteRuntimePtyTransport( try { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - return await attachHostSessionMirror(options) + return await attachHostSessionMirror(options, true, undefined, connectLifecycleEpoch) } if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { @@ -1762,12 +1829,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - lifecycleEpoch += 1 + const attachLifecycleEpoch = ++lifecycleEpoch const generation = ++attachGeneration cancelTerminalCreateRetryWait() recovery.cancel() recoveryRequiresReplacement = false clearPublishedHandleWait() + lastAttachOptions = options storedCallbacks = options.callbacks terminalEnded = false connecting = true @@ -1798,7 +1866,7 @@ export function createRemoteRuntimePtyTransport( const persistedHandle = nextHandle void (async () => { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation) + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) return } if (!tabId || !leafId || !worktreeId) { @@ -1845,10 +1913,15 @@ export function createRemoteRuntimePtyTransport( } await adoptResolvedHostPane(resolved, options, false, generation) })().catch((error) => { - if (generation !== attachGeneration || destroyed) { + if ( + generation !== attachGeneration || + attachLifecycleEpoch !== lifecycleEpoch || + destroyed + ) { return } clearPendingViewportClaim() + recovery.cancel() handleRemoteTerminalError(error) }) }, @@ -1998,6 +2071,23 @@ export function createRemoteRuntimePtyTransport( getRecoveryState, retryRecovery() { + if ( + !destroyed && + !terminalEnded && + !connected && + isWebTerminalSurfaceTabId(tabId ?? '') && + recovery.currentPhase === 'disconnected' + ) { + recovery.cancel() + if (lastAttachOptions) { + transport.attach(lastAttachOptions) + return true + } + if (lastConnectOptions) { + void transport.connect(lastConnectOptions) + return true + } + } if ( !destroyed && !terminalEnded && diff --git a/src/renderer/src/lib/react-renderer-root.test.ts b/src/renderer/src/lib/react-renderer-root.test.ts new file mode 100644 index 00000000000..3592196f969 --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { getOrCreateRendererRoot } from './react-renderer-root' + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn() +})) + +beforeEach(() => { + vi.mocked(createRoot).mockReset() +}) + +describe('getOrCreateRendererRoot', () => { + it('reuses the root retained by an HMR entry module', () => { + const hotData: { orcaRendererRoot?: Root } = {} + const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValue(root) + + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(createRoot).toHaveBeenCalledTimes(1) + }) + + it('creates an independent root without HMR state', () => { + const first = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + const second = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValueOnce(first).mockReturnValueOnce(second) + + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(first) + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(second) + }) +}) diff --git a/src/renderer/src/lib/react-renderer-root.ts b/src/renderer/src/lib/react-renderer-root.ts new file mode 100644 index 00000000000..0a73210ecfa --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.ts @@ -0,0 +1,20 @@ +import { createRoot, type Root } from 'react-dom/client' + +type RendererRootHotData = { + orcaRendererRoot?: Root +} + +export function getOrCreateRendererRoot( + container: HTMLElement, + hotData?: RendererRootHotData +): Root { + const existingRoot = hotData?.orcaRendererRoot + if (existingRoot) { + return existingRoot + } + const root = createRoot(container) + if (hotData) { + hotData.orcaRendererRoot = root + } + return root +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a980c8ed730..7acc230bb5a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -14,6 +13,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() @@ -55,7 +55,7 @@ function RendererRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index e68565f509c..15f1be88fb6 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode, useEffect } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import { DashboardPopoutRoot } from './components/dashboard-popout/DashboardPopoutRoot' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -15,6 +14,7 @@ import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/types' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -114,7 +114,7 @@ function PopoutRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( From 0281496c6f49f5326a91f54aec10c98471badfe5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:07:53 -0700 Subject: [PATCH 034/123] fix(remote): keep resumed agent tabs stable on headed hosts (#11448) * fix(remote): keep resumed agent tabs stable * fix(remote): retain resume identity from older snapshots * fix(remote): refresh mirrored resume attribution * fix(remote): preserve headed runtime agent tabs --- src/main/runtime/orca-runtime.test.ts | 199 +++++++++++++++++- src/main/runtime/orca-runtime.ts | 52 ++++- .../src/runtime/web-session-tabs-sync.test.ts | 122 ++++++++++- .../src/runtime/web-session-tabs-sync.ts | 22 +- 4 files changed, 383 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 8c41f25690a..3855ad4e37b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -25026,6 +25026,117 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps a live headed runtime-owned tab until its explicit close', async () => { + const ptyId = 'local-runtime-owned-pty' + const splitPtyId = 'local-runtime-owned-split-pty' + const tabId = 'runtime-session-tab' + const leafId = HEADLESS_LEAF_ID + const kill = vi.fn(() => true) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO_ID, + activeWorktreeId: TEST_WORKTREE_ID, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: tabId, + ptyId: null, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: makeHeadlessTerminalLayout({ [leafId]: undefined }) + } + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValueOnce({ id: ptyId }).mockResolvedValueOnce({ id: splitPtyId }), + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: ptyId, + cwd: TEST_WORKTREE_PATH, + title: 'Codex', + worktreeId: TEST_WORKTREE_ID + }, + { + id: splitPtyId, + cwd: TEST_WORKTREE_PATH, + title: 'Codex', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + const publishRendererOmission = (snapshotVersion: number): void => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headed-runtime', + snapshotVersion, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + } + runtime.attachWindow(1) + publishRendererOmission(1) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + presentation: 'background', + persistHostSessionBinding: true, + tabId, + leafId, + launchAgent: 'codex' + }) + const split = await runtime.splitTerminal(created.handle, { direction: 'vertical' }) + publishRendererOmission(2) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `${tabId}::${leafId}`, + parentTabId: tabId, + ptyId, + status: 'ready', + terminal: created.handle + }), + expect.objectContaining({ + parentTabId: tabId, + ptyId: splitPtyId, + status: 'ready', + terminal: split.handle + }) + ]) + ) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toHaveLength(2) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, tabId, { reason: 'user' }) + publishRendererOmission(3) + + expect(kill).toHaveBeenCalledWith(ptyId) + expect(kill).toHaveBeenCalledWith(splitPtyId) + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) + }) + it('publishes laptop-created remote runtime terminals to phone session tabs', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) @@ -29289,7 +29400,7 @@ describe('OrcaRuntimeService', () => { { tabId: 'tab-renderer', worktreeId: TEST_WORKTREE_ID, - leafId: 'pane:1', + leafId: HEADLESS_LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-renderer', paneTitle: null @@ -29306,10 +29417,12 @@ describe('OrcaRuntimeService', () => { tabs: [ { type: 'terminal', - id: 'tab-renderer::pane:1', + id: `tab-renderer::${HEADLESS_LEAF_ID}`, parentTabId: 'tab-renderer', - leafId: 'pane:1', + leafId: HEADLESS_LEAF_ID, + ptyId: 'pty-renderer', title: 'Terminal', + viewMode: 'chat', isActive: false } ] @@ -29350,6 +29463,86 @@ describe('OrcaRuntimeService', () => { ) expect(focusTerminal).not.toHaveBeenCalled() expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false }) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([ + expect.objectContaining({ parentTabId: 'tab-renderer', ptyId: 'pty-renderer' }) + ]) + + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 3, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_SECOND_LEAF_ID, + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 4, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) }) it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1328dc5c6ed..57d80f75a59 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1216,6 +1216,7 @@ type RuntimePtyWorktreeRecord = { incarnationId: PtyIncarnationId | null worktreeId: string connectionId: string | null + runtimeSessionOwned: boolean // Why: a Windows host can own both native and WSL panes; preamble command // selection must follow the pane that executes it, not process.platform. isWsl: boolean | null @@ -5982,6 +5983,35 @@ export class OrcaRuntimeService { return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) } + private hasLiveRuntimeSessionOwnedPtyBinding( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) + return pty?.connected === true && pty.runtimeSessionOwned + } + + private clearRuntimeSessionOwnershipForMobileTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + parentTabId: string + ): void { + for (const tab of snapshot.tabs) { + if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) { + continue + } + const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( + (ptyId): ptyId is string => typeof ptyId === 'string' + ) + for (const ptyId of ptyIds) { + const pty = this.ptysById.get(ptyId) + if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) { + pty.runtimeSessionOwned = false + } + } + } + } + // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never @@ -7440,6 +7470,7 @@ export class OrcaRuntimeService { this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) return { closed: true } } // Why: notifier implementations without the acknowledged relay may expose @@ -7467,6 +7498,7 @@ export class OrcaRuntimeService { // parent tab id. Closing that parent should close the desktop tab, not // just whichever leaf happened to be first in the session snapshot. this.notifier?.closeTerminal(tab.parentTabId) + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) } } else if (tab.type === 'browser' && this.offscreenBrowserBackend) { // Why: headless browser tabs are offscreen WebContents with no renderer to @@ -7611,6 +7643,7 @@ export class OrcaRuntimeService { options: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) // Why: local provider ids can be reused after restart, so a dormant // persisted id is not kill authority. SSH relay ids remain durable exact @@ -8782,6 +8815,9 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId, + ...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`) + ? { runtimeSessionOwned: true } + : {}), ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) @@ -12806,6 +12842,7 @@ export class OrcaRuntimeService { this.agentDetector?.onExit(ptyId) if (pty) { pty.connected = false + pty.runtimeSessionOwned = false pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) @@ -24193,6 +24230,9 @@ export class OrcaRuntimeService { }) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { + if (launchOpts.persistHostSessionBinding) { + pty.runtimeSessionOwned = true + } if (launchOpts.title) { const observedAt = this.nextTitleObservationSequence() pty.title = launchOpts.title @@ -25101,6 +25141,10 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + if (pty) { + pty.runtimeSessionOwned = true + } if ( existing && this.isReadyMobileTerminalSurface(existing) && @@ -25109,7 +25153,6 @@ export class OrcaRuntimeService { // Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue. return existing } - const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { return existing @@ -25554,6 +25597,7 @@ export class OrcaRuntimeService { if (createdPty) { createdPty.tabId = parentTabId createdPty.paneKey = paneKey + createdPty.runtimeSessionOwned = pty.runtimeSessionOwned } try { @@ -27428,6 +27472,7 @@ export class OrcaRuntimeService { | 'paneKey' | 'title' | 'connectionId' + | 'runtimeSessionOwned' | 'isWsl' | 'wslDistro' | 'incarnationId' @@ -27452,6 +27497,7 @@ export class OrcaRuntimeService { incarnationId: state.incarnationId ?? null, worktreeId, connectionId, + runtimeSessionOwned: state.runtimeSessionOwned ?? false, isWsl: state.isWsl ?? null, wslDistro, tabId: state.tabId ?? null, @@ -27509,6 +27555,9 @@ export class OrcaRuntimeService { this.wslDistroByPtyId.delete(ptyId) } } + if (state.runtimeSessionOwned !== undefined) { + pty.runtimeSessionOwned = state.runtimeSessionOwned + } if (state.isWsl !== undefined) { pty.isWsl = state.isWsl } @@ -28303,6 +28352,7 @@ export class OrcaRuntimeService { // are runtime-owned and preservable. return ( this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || + this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) || this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b2049c38936..fcdfbddecb3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -1866,7 +1866,10 @@ describe('applyWebSessionTabsSnapshot', () => { stateStartedAt: NOW - 1_000, agentType: 'codex', paneKey: hostPaneKey, + tabId: 'host-tab-1', + worktreeId: WT, terminalTitle: 'codex [working]', + providerSession: { key: 'session_id', id: 'session-1' }, stateHistory: [] } } @@ -1882,6 +1885,9 @@ describe('applyWebSessionTabsSnapshot', () => { prompt: 'fix web parity', agentType: 'codex', paneKey: mirroredPaneKey, + tabId: mirroredId, + worktreeId: WT, + providerSession: { key: 'session_id', id: 'session-1' }, terminalTitle: 'codex [working]' }) expect(patch.agentStatusByPaneKey?.[hostPaneKey]).toBeUndefined() @@ -1889,7 +1895,7 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe(1) }) - it('bumps aggregate epochs when a mirrored same-state entry gains attribution', () => { + it('repairs mirrored same-state attribution and retains identity from an older snapshot', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const snapshot = makeSnapshot([ { @@ -1910,6 +1916,7 @@ describe('applyWebSessionTabsSnapshot', () => { paneKey: hostPaneKey, worktreeId: WT, tabId: 'host-tab-1', + providerSession: { key: 'session_id', id: 'session-1' }, stateHistory: [] } } @@ -1922,11 +1929,15 @@ describe('applyWebSessionTabsSnapshot', () => { ) as Partial const mirroredPaneKey = Object.keys(initial.agentStatusByPaneKey ?? {})[0]! const existing = initial.agentStatusByPaneKey![mirroredPaneKey]! - const patch = applyWebSessionTabsSnapshot( + const attributionPatch = applyWebSessionTabsSnapshot( makeState({ ...initial, agentStatusByPaneKey: { - [mirroredPaneKey]: { ...existing, worktreeId: 'stale-worktree', tabId: 'stale-tab' } + [mirroredPaneKey]: { + ...existing, + worktreeId: 'stale-worktree', + tabId: 'stale-tab' + } }, agentStatusEpoch: 7, sortEpoch: 11 @@ -1936,8 +1947,109 @@ describe('applyWebSessionTabsSnapshot', () => { NOW ) as Partial - expect(patch.agentStatusEpoch).toBe(8) - expect(patch.sortEpoch).toBe(12) + expect(attributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + worktreeId: existing.worktreeId, + tabId: existing.tabId + }) + expect(attributionPatch.agentStatusEpoch).toBe(8) + expect(attributionPatch.sortEpoch).toBe(12) + + const fresherAttributionPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...existing, + updatedAt: NOW, + worktreeId: 'stale-worktree', + tabId: 'stale-tab', + providerSession: undefined + } + }, + agentStatusEpoch: 7, + sortEpoch: 11 + }), + { ...snapshot, snapshotVersion: 3 }, + ENV, + NOW + ) as Partial + + expect(fresherAttributionPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + worktreeId: existing.worktreeId, + tabId: existing.tabId, + updatedAt: NOW, + providerSession: { key: 'session_id', id: 'session-1' } + }) + expect(fresherAttributionPatch.agentStatusEpoch).toBe(8) + expect(fresherAttributionPatch.sortEpoch).toBe(12) + + const identityPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + ...attributionPatch, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...attributionPatch.agentStatusByPaneKey![mirroredPaneKey]!, + updatedAt: NOW, + providerSession: undefined + } + } + }), + { ...snapshot, snapshotVersion: 4 }, + ENV, + NOW + ) as Partial + + expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toEqual({ + key: 'session_id', + id: 'session-1' + }) + expect(identityPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.updatedAt).toBe(NOW) + expect(identityPatch.agentStatusEpoch).toBe(8) + expect(identityPatch.sortEpoch).toBe(12) + + const nextTurnPatch = applyWebSessionTabsSnapshot( + makeState({ + ...initial, + agentStatusByPaneKey: { + [mirroredPaneKey]: { + ...existing, + state: 'working', + updatedAt: NOW, + stateStartedAt: NOW, + worktreeId: 'stale-worktree', + tabId: 'stale-tab', + providerSession: undefined + } + } + }), + { + ...snapshot, + snapshotVersion: 5, + tabs: snapshot.tabs.map((tab) => + tab.type === 'terminal' && tab.agentStatus + ? { + ...tab, + agentStatus: { + ...tab.agentStatus, + state: 'done', + providerSession: { key: 'session_id', id: 'previous-session' } + } + } + : tab + ) + }, + ENV, + NOW + ) as Partial + + expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({ + state: 'working', + stateStartedAt: NOW, + worktreeId: WT, + tabId: existing.tabId + }) + expect(nextTurnPatch.agentStatusByPaneKey?.[mirroredPaneKey]?.providerSession).toBeUndefined() }) it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 022ba9503b5..ca5f56a2ae0 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -8,6 +8,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionBrowserTab, @@ -674,7 +675,8 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null }) return { ...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent), - paneKey + paneKey, + tabId: toWebTerminalSurfaceTabId(surface.parentTabId) } } @@ -711,10 +713,23 @@ function buildMirroredAgentStatusPatch( continue } const existing = state.agentStatusByPaneKey[entry.paneKey] - // Why: an active web stream can report a fresher OSC 9999 status before the next host snapshot, so don't rewind it with an older host publication. + // Why: keep fresher OSC state while taking remapped ownership metadata from the authoritative host snapshot. + const hostIdentityPredatesCurrentTurn = + existing !== undefined && + entry.state === 'done' && + existing.state !== 'done' && + existing.stateStartedAt > entry.stateStartedAt const nextEntry = existing && existing.updatedAt > entry.updatedAt - ? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType) + ? { + ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), + paneKey: entry.paneKey, + worktreeId: entry.worktreeId ?? existing.worktreeId, + tabId: entry.tabId, + providerSession: + existing.providerSession ?? + (hostIdentityPredatesCurrentTurn ? undefined : entry.providerSession) + } : entry nextByPaneKey.set(entry.paneKey, nextEntry) } @@ -1346,6 +1361,7 @@ function agentStatusEntryEqual(a: AgentStatusEntry | undefined, b: AgentStatusEn a.lastAssistantMessage === b.lastAssistantMessage && a.interrupted === b.interrupted && a.promptInteractionKey === b.promptInteractionKey && + agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) && sameAgentStateHistory(a.stateHistory, b.stateHistory) ) } From 1b4d2f30ea9d6b82b603c1d6b3fc42ec5af15fc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:20:02 -0700 Subject: [PATCH 035/123] fix(runtime): preserve PTY identity during desktop promotion (#11611) * fix(runtime): preserve PTY identity during desktop promotion * fix(runtime): guard final catalog hydration --- src/renderer/src/App.tsx | 7 + src/renderer/src/app-startup-routing.test.ts | 11 + .../slices/repos-all-hosts-generation.test.ts | 196 +++++++++++++++++- .../store/slices/repos-stale-fetch.test.ts | 125 ++++++++++- src/renderer/src/store/slices/repos.ts | 116 +++++++++-- .../headless-serve-desktop-activation.spec.ts | 60 +++++- 6 files changed, 496 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e5b5d7387d8..cda226ef620 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -445,6 +445,7 @@ function App(): React.JSX.Element { toggleSidebar: s.toggleSidebar, fetchRepos: s.fetchRepos, fetchReposForAllHosts: s.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, fetchProjectGroups: s.fetchProjectGroups, fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, fetchFolderWorkspaces: s.fetchFolderWorkspaces, @@ -899,6 +900,9 @@ function App(): React.JSX.Element { await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) ) + await timeRendererStartupStep('repo-catalog-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) // Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts), // so keep this chain ordered while overlapping it with session-scoped hydration. const localCatalogChain = (async () => { @@ -951,6 +955,9 @@ function App(): React.JSX.Element { } const sessionRead = sessionOutcome.value await keybindingsPromise + await timeRendererStartupStep('repo-catalog-final-settlement', () => + actions.awaitLocalRepoCatalogSettlement() + ) if (!cancelled) { const sessionHydrationOptions = { additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 85dd64ba574..f638ae82083 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -20,6 +20,12 @@ describe('renderer startup runtime routing', () => { const localReposIndex = indexInStartupBlock( "actions.fetchReposForAllHosts({ remoteHosts: 'skip' })" ) + const repoCatalogSettlementIndex = indexInStartupBlock( + "timeRendererStartupStep('repo-catalog-settlement'" + ) + const finalRepoCatalogSettlementIndex = indexInStartupBlock( + "timeRendererStartupStep('repo-catalog-final-settlement'" + ) const localGroupsIndex = indexInStartupBlock( "actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })" ) @@ -39,6 +45,10 @@ describe('renderer startup runtime routing', () => { expect(settingsIndex).toBeLessThan(uiGetIndex) expect(uiGetIndex).toBeLessThan(hydrateUiIndex) expect(hydrateUiIndex).toBeLessThan(localReposIndex) + expect(localReposIndex).toBeLessThan(repoCatalogSettlementIndex) + expect(repoCatalogSettlementIndex).toBeLessThan(sessionIndex) + expect(sessionIndex).toBeLessThan(finalRepoCatalogSettlementIndex) + expect(finalRepoCatalogSettlementIndex).toBeLessThan(startupBlockEnd) // The local catalog chain stays internally ordered (folders merge against project groups). expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) @@ -75,6 +85,7 @@ describe('renderer startup runtime routing', () => { // The catalog and selective hydration chains overlap, but both settle before recovery or hydration. const joinStart = indexInStartupBlock('await Promise.allSettled([') expect(joinStart).toBeGreaterThan(hydrateUiIndex) + expect(joinStart).toBeLessThan(finalRepoCatalogSettlementIndex) const joinBlock = source.slice(joinStart, startupBlockEnd) expect(joinBlock).toContain('hydrationSessionChain') expect(joinBlock).toContain('localCatalogChain') diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index a6859b21bb3..24e2c770dce 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -23,14 +23,29 @@ const remoteRepo: Repo = { addedAt: 1 } +const staleRemoteRepo: Repo = { + ...remoteRepo, + path: '/remote-stale', + displayName: 'Remote stale' +} + +const freshRemoteRepo: Repo = { + ...remoteRepo, + path: '/remote-fresh', + displayName: 'Remote fresh' +} + const runtimeEnvironmentCall = vi.fn() +const reposList = vi.fn() beforeEach(() => { clearRuntimeCompatibilityCacheForTests() runtimeEnvironmentCall.mockReset() + reposList.mockReset() + reposList.mockResolvedValue([localRepo]) vi.stubGlobal('window', { api: { - repos: { list: vi.fn().mockResolvedValue([localRepo]) }, + repos: { list: reposList }, projects: { list: vi.fn().mockResolvedValue([]), listHostSetups: vi.fn().mockResolvedValue([]) @@ -46,6 +61,185 @@ beforeEach(() => { }) describe('fetchReposForAllHosts generation', () => { + it('settles startup hydration after the local catalog without waiting for remotes', async () => { + let resolveRemote!: (value: unknown) => void + let markRemoteStarted!: () => void + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + const remoteStarted = new Promise((resolve) => { + markRemoteStarted = resolve + }) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRemoteStarted() + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const load = store.getState().fetchReposForAllHosts() + await remoteStarted + await store.getState().awaitLocalRepoCatalogSettlement() + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await load + }) + + it('does not let a remote refresh supersede or delay local startup settlement', async () => { + let resolveLocal!: (repos: Repo[]) => void + let resolveRemote!: (value: unknown) => void + const local = new Promise((resolve) => { + resolveLocal = resolve + }) + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + reposList.mockReturnValueOnce(local) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const remoteRefresh = store.getState().fetchRepos() + let remoteSettled = false + void remoteRefresh.then(() => { + remoteSettled = true + }) + + resolveLocal([localRepo]) + await startup + await store.getState().awaitLocalRepoCatalogSettlement() + + expect(remoteSettled).toBe(false) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await remoteRefresh + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) + }) + + it('keeps a newer Connect-flow catalog when an older all-host response resolves last', async () => { + let resolveOlder!: (value: unknown) => void + let markOlderStarted!: () => void + const older = new Promise((resolve) => { + resolveOlder = resolve + }) + const olderStarted = new Promise((resolve) => { + markOlderStarted = resolve + }) + let repoListCalls = 0 + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + repoListCalls++ + if (repoListCalls === 1) { + markOlderStarted() + return older + } + return { + id: 'rpc-fresh', + ok: true, + result: { repos: [freshRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const allHosts = store.getState().fetchReposForAllHosts() + await olderStarted + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + resolveOlder({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await allHosts + + expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe( + freshRemoteRepo.path + ) + }) + + it('keeps a newer all-host catalog when an older Connect-flow response resolves last', async () => { + let resolveOlder!: (value: unknown) => void + const older = new Promise((resolve) => { + resolveOlder = resolve + }) + let repoListCalls = 0 + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method !== 'repo.list') { + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + repoListCalls++ + return repoListCalls === 1 + ? older + : { + id: 'rpc-fresh', + ok: true, + result: { repos: [freshRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const connect = store.getState().fetchRuntimeEnvironmentRepos('env-1') + await store.getState().fetchReposForAllHosts() + + resolveOlder({ + id: 'rpc-stale', + ok: true, + result: { repos: [staleRemoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await connect + + expect(store.getState().repos.find((repo) => repo.id === remoteRepo.id)?.path).toBe( + freshRemoteRepo.path + ) + }) + it('does not validate repo UI from a superseded refresh', async () => { let resolveOlderRemote!: (value: unknown) => void let resolveNewerRemote!: (value: unknown) => void diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index a536224b860..c3c73d204ce 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { Repo } from '../../../../shared/types' +import { createTestStore, makeLayout, makeTab } from './store-test-helpers' +import type { Repo, WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' const localRepo: Repo = { @@ -20,6 +21,34 @@ const remoteRepo: Repo = { } const reposList = vi.fn() +const promotionWorktreeId = 'local-repo::/local' +const promotionTabId = 'restored-tab' +const promotionPtyId = 'original-daemon-pty' + +function makePromotionSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: localRepo.id, + activeWorktreeId: promotionWorktreeId, + activeTabId: promotionTabId, + activeWorktreeIdsOnShutdown: [promotionWorktreeId], + tabsByWorktree: { + [promotionWorktreeId]: [ + makeTab({ + id: promotionTabId, + worktreeId: promotionWorktreeId, + ptyId: promotionPtyId + }) + ] + }, + terminalLayoutsByTabId: { + [promotionTabId]: { + ...makeLayout(), + ptyIdsByLeafId: { 'pane:1': promotionPtyId } + } + } + } +} beforeEach(() => { clearRuntimeCompatibilityCacheForTests() @@ -74,4 +103,96 @@ describe('repos slice stale-fetch race (#7020)', () => { await stale expect(store.getState().repos).toEqual([]) }) + + it('waits for the superseding catalog fetch before startup hydration', async () => { + let resolveStartup!: (repos: Repo[]) => void + let resolveRefresh!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos() + resolveStartup([localRepo]) + await startup + + let settled = false + const settlement = store + .getState() + .awaitLocalRepoCatalogSettlement() + .then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, settlement]) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + store.getState().hydrateWorkspaceSession(makePromotionSession()) + + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + [promotionTabId]: promotionPtyId + }) + expect(store.getState().terminalLayoutsByTabId[promotionTabId]?.ptyIdsByLeafId).toEqual({ + 'pane:1': promotionPtyId + }) + }) + + it('waits for a refresh started after initial settlement before startup hydration', async () => { + let resolveRefresh!: (repos: Repo[]) => void + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockResolvedValueOnce([localRepo]).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().awaitLocalRepoCatalogSettlement() + const refresh = store.getState().fetchRepos() + let hydrated = false + const hydration = (async () => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + hydrated = true + })() + + await Promise.resolve() + expect(hydrated).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, hydration]) + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId[promotionTabId]).toBe(promotionPtyId) + }) + + it('rejects startup settlement when the superseding local catalog fails', async () => { + let resolveStartup!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockRejectedValueOnce(new Error('catalog failed')) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos() + resolveStartup([localRepo]) + await Promise.all([startup, refresh]) + + const hydration = async (): Promise => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + } + await expect(hydration()).rejects.toThrow('catalog failed') + expect(store.getState().tabsByWorktree).toEqual({}) + expect(store.getState().terminalLayoutsByTabId).toEqual({}) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index fc55fa9148d..dac56c84102 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1530,12 +1530,13 @@ export type RepoSlice = { folderWorkspaces: FolderWorkspace[] folderWorkspacePathStatuses: Record activeRepoId: string | null - // Monotonic sequence so an overlapping fetchRepos can drop its own stale result (#7020). + // Monotonic sequence so overlapping catalog fetches can drop stale same-host results (#7020). reposFetchGeneration: number pendingSshRepoReadoptions: SshRepoReadoption[] recordSshRepoReadoptions: (readoptions: SshRepoReadoption[]) => void fetchRepos: () => Promise fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise + awaitLocalRepoCatalogSettlement: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise fetchProjectGroups: () => Promise fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise @@ -1633,6 +1634,63 @@ export type RepoSlice = { reorderRepos: (orderedIds: string[]) => Promise } +type LocalRepoCatalogFetchOutcome = + | { status: 'fulfilled' } + | { status: 'rejected'; reason: unknown } + +const latestLocalRepoCatalogFetchByStore = new WeakMap< + () => AppState, + Promise +>() +const latestRepoCatalogGenerationByHostByStore = new WeakMap<() => AppState, Map>() +const latestAllHostRepoCatalogGenerationByStore = new WeakMap<() => AppState, number>() + +function startLocalRepoCatalogFetch( + get: () => AppState +): (outcome: LocalRepoCatalogFetchOutcome) => void { + let settle: (outcome: LocalRepoCatalogFetchOutcome) => void = () => undefined + const settlement = new Promise((resolve) => { + settle = resolve + }) + latestLocalRepoCatalogFetchByStore.set(get, settlement) + return settle +} + +async function awaitLatestLocalRepoCatalogFetch(get: () => AppState): Promise { + while (true) { + const pending = latestLocalRepoCatalogFetchByStore.get(get) + if (!pending) { + return + } + const outcome = await pending + if (latestLocalRepoCatalogFetchByStore.get(get) === pending) { + if (outcome.status === 'rejected') { + throw outcome.reason + } + return + } + } +} + +function claimRepoCatalogGeneration(get: () => AppState, hostId: string, generation: number): void { + let generations = latestRepoCatalogGenerationByHostByStore.get(get) + if (!generations) { + generations = new Map() + latestRepoCatalogGenerationByHostByStore.set(get, generations) + } + if ((generations.get(hostId) ?? 0) < generation) { + generations.set(hostId, generation) + } +} + +function isLatestRepoCatalogGeneration( + get: () => AppState, + hostId: string, + generation: number +): boolean { + return latestRepoCatalogGenerationByHostByStore.get(get)?.get(hostId) === generation +} + export const createRepoSlice: StateCreator = (set, get) => ({ repos: [], projects: [], @@ -1670,17 +1728,22 @@ export const createRepoSlice: StateCreator = (set, }), fetchRepos: async () => { + const target = getActiveRuntimeTarget(get().settings) + const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = + target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } // Why: overlapping repos:changed fetches can resolve out of order; a stale one must not overwrite a newer result and resurrect deleted projects (#7020). let generation = 0 set((s) => { generation = s.reposFetchGeneration + 1 return { reposFetchGeneration: generation } }) + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, generation) try { - const target = getActiveRuntimeTarget(get().settings) const catalog = await fetchRepoCatalogForTarget(target) - // A newer fetchRepos superseded us while we awaited — drop this stale result. - if (get().reposFetchGeneration !== generation) { + // A newer same-host fetch superseded us while we awaited — drop this stale result. + if (!isLatestRepoCatalogGeneration(get, targetHostId, generation)) { return } let finalizedHostRepos: Repo[] = [] @@ -1731,7 +1794,10 @@ export const createRepoSlice: StateCreator = (set, }) scheduleSafeAutoForkSync(get, finalizedHostRepos) } catch (err) { + localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) + } finally { + settleLocalCatalog(localCatalogOutcome) } }, @@ -1740,11 +1806,19 @@ export const createRepoSlice: StateCreator = (set, runtimeRepoFetchGenerationByEnvironment.set(environmentId, requestGeneration) const connectionGeneration = getEnvironmentSshStateGeneration(environmentId) const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) + let catalogGeneration = 0 + set((s) => { + catalogGeneration = s.reposFetchGeneration + 1 + return { reposFetchGeneration: catalogGeneration } + }) + const target = { kind: 'environment' as const, environmentId } + const targetHostId = getRuntimeTargetHostId(target) + claimRepoCatalogGeneration(get, targetHostId, catalogGeneration) try { - const target = { kind: 'environment' as const, environmentId } const catalog = await fetchRepoCatalogForTarget(target) if ( runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1754,6 +1828,7 @@ export const createRepoSlice: StateCreator = (set, set((s) => { if ( runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration || + !isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) || getEnvironmentSshStateGeneration(environmentId) !== connectionGeneration || getRuntimeEnvironmentConnectionGeneration(environmentId) !== runtimeConnectionGeneration ) { @@ -1810,15 +1885,21 @@ export const createRepoSlice: StateCreator = (set, }, fetchReposForAllHosts: async (options) => { + const settleLocalCatalog = startLocalRepoCatalogFetch(get) let generation = 0 set((s) => { generation = s.reposFetchGeneration + 1 return { reposFetchGeneration: generation } }) + latestAllHostRepoCatalogGenerationByStore.set(get, generation) + claimRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) // Why: fetching only the active host hides every other host's repos ("my projects vanished"); load local + all runtime envs, each failing soft. const applyCatalog = (catalog: FetchedRepoCatalog): void => { // Why: a concurrent all-host refresh must not let the older catalog resurrect a migrated SSH owner. - if (get().reposFetchGeneration !== generation) { + if ( + latestAllHostRepoCatalogGenerationByStore.get(get) !== generation || + !isLatestRepoCatalogGeneration(get, catalog.hostId, generation) + ) { return } let hostRepos: Repo[] = [] @@ -1880,13 +1961,20 @@ export const createRepoSlice: StateCreator = (set, // Local first so local repos are present even if a remote fetch stalls. let failed = false + let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } try { applyCatalog(await fetchRepoCatalogForTarget({ kind: 'local' })) } catch (err) { failed = true + localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch local repos for all-host load:', err) } - if (get().reposFetchGeneration !== generation) { + // Why: startup hydration needs the newest local catalog, not unreachable remote hosts. + settleLocalCatalog(localCatalogOutcome) + if ( + get().reposFetchGeneration !== generation && + !isLatestRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) + ) { return } if (options?.remoteHosts === 'skip') { @@ -1897,13 +1985,13 @@ export const createRepoSlice: StateCreator = (set, // Why: unreachable remotes can spend the full connect timeout; merge each resolved host via the state updater so parallel loads don't clobber. await Promise.all( environments.map(async (environment) => { + const target = { + kind: 'environment' as const, + environmentId: environment.id + } + claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation) try { - applyCatalog( - await fetchRepoCatalogForTarget({ - kind: 'environment', - environmentId: environment.id - }) - ) + applyCatalog(await fetchRepoCatalogForTarget(target)) } catch (err) { failed = true console.warn(`Skipped repos for runtime environment ${environment.id}:`, err) @@ -1916,6 +2004,8 @@ export const createRepoSlice: StateCreator = (set, } }, + awaitLocalRepoCatalogSettlement: () => awaitLatestLocalRepoCatalogFetch(get), + fetchProjectGroups: async () => { try { const target = getActiveRuntimeTarget(get().settings) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 1a15cfd2897..e19e0dabf9f 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -30,6 +30,8 @@ import type { RuntimeTerminalRead } from '../../src/shared/runtime-types' import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { parsePaneKey } from '../../src/shared/stable-pane-id' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') const electronPath = path.join( @@ -68,6 +70,35 @@ function readDaemonPid(userDataDir: string): number { return parsed.pid } +function readPersistedPromotionBinding( + userDataDir: string, + worktreeId: string, + tabId: string, + leafId: string +): { tabId: string; leafId: string; ptyId: string } | null { + try { + const persisted = JSON.parse( + readFileSync( + path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), + 'utf8' + ) + ) as { + workspaceSession?: { + tabsByWorktree?: Record + terminalLayoutsByTabId?: Record }> + } + } + const tab = persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === tabId + ) + const ptyId = + persisted.workspaceSession?.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId?.[leafId] + return tab && ptyId ? { tabId, leafId, ptyId } : null + } catch { + return null + } +} + async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) { return true @@ -146,8 +177,11 @@ test('promotes the headless owner without replacing its daemon terminal', async title: 'Serve promotion continuity' }) const terminal = created.result.terminal - if (!terminal.ptyId) { - throw new Error('Headless terminal did not expose its daemon PTY id') + const originalPtyId = terminal.ptyId + const originalTabId = terminal.tabId + const paneIdentity = terminal.paneKey ? parsePaneKey(terminal.paneKey) : null + if (!originalPtyId || !originalTabId || !paneIdentity) { + throw new Error('Headless terminal did not expose its durable pane and daemon PTY identity') } const beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}` @@ -190,6 +224,26 @@ test('promotes the headless owner without replacing its daemon terminal', async }) } + await expect + .poll( + () => + readPersistedPromotionBinding( + userDataDir, + terminal.worktreeId, + originalTabId, + paneIdentity.leafId + ), + { + timeout: 15_000, + message: 'headless terminal binding was not persisted during desktop promotion' + } + ) + .toEqual({ + tabId: originalTabId, + leafId: paneIdentity.leafId, + ptyId: originalPtyId + }) + const page = await serveApp.firstWindow({ timeout: 60_000 }) await page.waitForLoadState('domcontentloaded') await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) @@ -223,6 +277,6 @@ test('promotes the headless owner without replacing its daemon terminal', async await closeElectronAppForE2E(serveApp) } await cleanupE2EDaemons(userDataDir) - rmSync(userDataDir, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }) } }) From 14de3fa14d9bc061676ceb3b543e0dde1d9c010c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:24:34 -0700 Subject: [PATCH 036/123] fix(computer): reap mac helper after client loss (#11493) * perf(computer): add mac helper owner-loss benchmark Measure the release helper's resident memory before and after its owner-session deadline. Record exact revisions, per-trial RSS, retained state, and clean-exit latency so lifecycle reclamation is reproducible. * fix(computer): reap mac helper after client loss Bind the detached macOS helper lifetime to authenticated socket ownership. Reap the helper after its final authenticated client disconnects, and add a startup deadline for sessions that never authenticate. * test(computer): harden owner benchmark cleanup * test(computer): make owner benchmark cleanup failure-safe * test(computer): close remaining owner cleanup races --- .github/workflows/computer-e2e.yml | 36 + config/scripts/computer-e2e-workflow.test.mjs | 62 ++ ...s-computer-helper-owner-loss-benchmark.mjs | 614 ++++++++++++++++++ ...-helper-owner-loss-group-recovery.test.mjs | 136 ++++ ...cos-computer-helper-owner-loss-metrics.mjs | 62 ++ ...s-computer-helper-owner-loss-processes.mjs | 418 ++++++++++++ ...puter-helper-owner-loss-processes.test.mjs | 558 ++++++++++++++++ ...mputer-helper-owner-loss-trial-cleanup.mjs | 63 ++ .../Sources/OrcaComputerUseMacOS/main.swift | 111 +++- .../AgentSessionOwnership.swift | 50 ++ ...AuthenticatedConnectionHangupMonitor.swift | 182 ++++++ .../AgentSessionOwnershipTests.swift | 104 +++ ...nticatedConnectionHangupMonitorTests.swift | 195 ++++++ package.json | 1 + 14 files changed, 2587 insertions(+), 5 deletions(-) create mode 100644 config/scripts/macos-computer-helper-owner-loss-benchmark.mjs create mode 100644 config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs create mode 100644 config/scripts/macos-computer-helper-owner-loss-metrics.mjs create mode 100644 config/scripts/macos-computer-helper-owner-loss-processes.mjs create mode 100644 config/scripts/macos-computer-helper-owner-loss-processes.test.mjs create mode 100644 config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 29996c70361..e6b16fab7a7 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -9,6 +9,12 @@ on: - 'config/scripts/build-windows-cli-launcher.mjs' - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-processes.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs' + - 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' - 'config/scripts/computer-use-modifier-safety.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -59,6 +65,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version-file: package.json @@ -85,6 +93,8 @@ jobs: config/scripts/build-windows-cli-launcher.test.mjs src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs + config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs + config/scripts/macos-computer-helper-owner-loss-processes.test.mjs config/scripts/computer-use-modifier-safety.test.mjs config/scripts/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs @@ -140,6 +150,32 @@ jobs: ORCA_COMPUTER_E2E: '1' run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts + mac-native-owner-smoke: + if: github.event_name == 'pull_request' + runs-on: macos-15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - uses: pnpm/action-setup@v6 + with: + run_install: false + - run: pnpm install --frozen-lockfile + - name: Owner-loss benchmark process cleanup + run: >- + pnpm vitest run + config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs + config/scripts/macos-computer-helper-owner-loss-processes.test.mjs + - name: Authenticated helper owner-loss smoke + run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1 + - name: Swift tests and signed universal helper verification + run: pnpm verify:computer-native + mac: # macOS Accessibility and Screen Recording require user-granted TCC entries. # Keep this on manual/scheduled permission-bearing runners instead of PR CI. diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index d6db7c5bbe9..a4230668bc7 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => { expect(triggerPaths).toEqual( expect.arrayContaining([ 'config/scripts/computer-e2e-workflow.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', 'config/scripts/computer-use-modifier-safety.test.mjs', 'config/scripts/computer-use-skill-guidance.test.mjs', 'config/scripts/computer-use-smoke.mjs', @@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => { const nativeSmokeRuns = workflow.jobs['native-smoke'].steps .map((step) => step.run) .filter((run) => typeof run === 'string') + const checkout = workflow.jobs['native-smoke'].steps.find( + (step) => step.uses === 'actions/checkout@v6' + ) const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run')) const expectedRegressionFiles = [ 'config/scripts/computer-e2e-workflow.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', 'config/scripts/computer-use-modifier-safety.test.mjs', 'config/scripts/computer-use-skill-guidance.test.mjs', 'config/scripts/computer-use-smoke.test.mjs', @@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => { 'src/shared/remote-runtime-client.test.ts' ] + expect(checkout.with['persist-credentials']).toBe(false) expect(regressionRun).toBeTruthy() for (const file of expectedRegressionFiles) { expect(regressionRun).toContain(file) } }) + it('builds and tests the macOS helper on pull requests without TCC e2e', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const job = workflow.jobs['mac-native-owner-smoke'] + const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string') + const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6') + + expect(job.if).toBe("github.event_name == 'pull_request'") + expect(job['runs-on']).toBe('macos-15') + expect(checkout.with['persist-credentials']).toBe(false) + expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1') + const cleanupRun = runs.find((run) => + run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs') + ) + expect(cleanupRun).toContain( + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs' + ) + expect(runs).toContain('pnpm verify:computer-native') + expect(runs.join('\n')).not.toContain('test:e2e:computer') + expect(workflow.on.pull_request.paths).toEqual( + expect.arrayContaining([ + 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs', + 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.mjs', + 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs', + 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs' + ]) + ) + }) + + it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => { + const benchmark = readFileSync( + join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'), + 'utf8' + ) + const cleanup = readFileSync( + join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'), + 'utf8' + ) + + expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]') + expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]") + expect(benchmark).toContain('cleanupOwnerLossTrial({') + const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)') + const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({') + expect(parseIndex).toBeGreaterThanOrEqual(0) + expect(cleanupIndex).toBeGreaterThanOrEqual(0) + expect(parseIndex).toBeLessThan(cleanupIndex) + expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)') + expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath') + expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'") + }) + it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') diff --git a/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs new file mode 100644 index 00000000000..8724cf8faf4 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-benchmark.mjs @@ -0,0 +1,614 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto' +import { execFileSync, fork } from 'node:child_process' +import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { + median, + percentile, + processSnapshot, + sampleProcess +} from './macos-computer-helper-owner-loss-metrics.mjs' +import { + benchmarkTrialNeedsCleanup, + parseBenchmarkTrialResult, + processIdentityIsCurrent, + signalProcessIdentity, + spawnBenchmarkProcess, + throwBenchmarkTrialFailures, + writeProcessRecord +} from './macos-computer-helper-owner-loss-processes.mjs' +import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs' + +const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL' +const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION' +const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH' +const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH' +const ACTIVE_REQUEST_COUNT = 100_000 +const DEFAULT_TRIALS = 3 +const OWNER_HOLD_MS = 31_000 +const PROCESS_EXIT_TIMEOUT_MS = 5_000 +const RETAIN_PROOF_MS = 3_000 +const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000 +const MIB = 1024 * 1024 +const scriptPath = import.meta.filename +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs') +const processCleanupPath = path.join( + import.meta.dirname, + 'macos-computer-helper-owner-loss-processes.mjs' +) +const trialCleanupPath = path.join( + import.meta.dirname, + 'macos-computer-helper-owner-loss-trial-cleanup.mjs' +) +const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js') +const helperAppPath = path.join( + repoRoot, + 'native', + 'computer-use-macos', + '.build', + 'release', + 'Orca Computer Use.app' +) +const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos') + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitForProcessExit(identity, timeoutMs) { + const startedAt = performance.now() + while (performance.now() - startedAt < timeoutMs) { + if (!processIdentityIsCurrent(identity)) { + return performance.now() - startedAt + } + await sleep(50) + } + return null +} + +async function stopProcess(identity) { + if (!identity || !processIdentityIsCurrent(identity)) { + return + } + signalProcessIdentity(identity, helperPath, 'SIGTERM') + if ((await waitForProcessExit(identity, 2_000)) !== null) { + return + } + signalProcessIdentity(identity, helperPath, 'SIGKILL') + await waitForProcessExit(identity, 2_000) +} + +function startSidecar() { + const errors = [] + const child = fork(sidecarPath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_COMPUTER_SIDECAR: '1', + ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath + } + }) + child.on('error', (error) => errors.push(error.stack ?? error.message)) + child.stderr?.on('data', (chunk) => errors.push(String(chunk))) + return { child, errors } +} + +function requestSidecar(sidecar, id, method) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup() + reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`)) + }, 10_000) + const onMessage = (message) => { + if (message?.id !== id) { + return + } + cleanup() + if (message.ok) { + resolve(message.result) + } else { + reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`)) + } + } + const onExit = (code, signal) => { + cleanup() + reject( + new Error( + `Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}` + ) + ) + } + const onError = (error) => { + cleanup() + reject(new Error(`Sidecar ${method} process error: ${error.message}`)) + } + const cleanup = () => { + clearTimeout(timeout) + sidecar.child.off('message', onMessage) + sidecar.child.off('exit', onExit) + sidecar.child.off('error', onError) + } + sidecar.child.on('message', onMessage) + sidecar.child.once('exit', onExit) + sidecar.child.once('error', onError) + try { + sidecar.child.send({ id, method, params: {} }) + } catch (error) { + onError(error) + } + }) +} + +async function waitForHelper(sidecarPid) { + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + for (const line of output.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/) + if ( + match && + Number(match[2]) === sidecarPid && + Number(match[3]) === Number(match[1]) && + match[4].includes(helperPath) && + match[4].includes(' --agent ') + ) { + return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] } + } + } + await sleep(50) + } + throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`) +} + +function socketPathFromCommand(command) { + const match = command.match(/ --agent (.+?) --token-file /) + if (!match) { + throw new Error(`Could not read helper socket path from command: ${command}`) + } + return match[1] +} + +function connectInvalidPeer(socketPath) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath) + let accepted = false + const timeout = setTimeout(() => { + socket.destroy() + reject(new Error('Invalid-peer connection timed out')) + }, 5_000) + let buffer = '' + const cleanup = () => { + clearTimeout(timeout) + socket.off('error', onError) + socket.off('data', onData) + } + const onError = (error) => { + if (accepted) { + return + } + cleanup() + reject(error) + } + const onData = (chunk) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline < 0) { + return + } + let response + try { + response = JSON.parse(buffer.slice(0, newline)) + } catch (error) { + cleanup() + socket.destroy() + reject(error) + return + } + if (response.ok !== false || response.error?.code !== 'permission_denied') { + cleanup() + socket.destroy() + reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`)) + return + } + clearTimeout(timeout) + socket.off('data', onData) + accepted = true + resolve(socket) + } + socket.setEncoding('utf8') + socket.on('error', onError) + socket.on('data', onData) + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n` + ) + }) + }) +} + +async function waitForChildExit(child, timeoutMs) { + if (child.exitCode !== null || child.signalCode !== null) { + return true + } + return await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + sleep(timeoutMs).then(() => false) + ]) +} + +async function startAuthenticatedSession() { + const sidecar = startSidecar() + let helper + try { + const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then( + (capabilities) => ({ capabilities }), + (error) => ({ error }) + ) + helper = await waitForHelper(sidecar.child.pid) + const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV] + if (!helperRecordPath) { + throw new Error('Missing helper process record path') + } + writeProcessRecord(helperRecordPath, helper) + const { capabilities, error } = await capabilitiesResult + if (error) { + throw error + } + if (capabilities?.protocolVersion !== 1) { + throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`) + } + return { authenticated: capabilities.protocolVersion === 1, sidecar, helper } + } catch (error) { + sidecar.child.kill('SIGKILL') + await stopProcess(helper) + throw error + } +} + +async function exerciseActiveRequests(sidecar) { + const latencies = [] + const startedAt = performance.now() + for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) { + const requestStartedAt = performance.now() + const result = await requestSidecar(sidecar, 10_000 + index, 'listApps') + if (!Array.isArray(result?.apps)) { + throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`) + } + latencies.push(performance.now() - requestStartedAt) + } + const totalMs = performance.now() - startedAt + return { + totalMs, + requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs, + medianLatencyMs: median(latencies), + p95LatencyMs: percentile(latencies, 0.95), + maxLatencyMs: Math.max(...latencies) + } +} + +async function verifyGracefulClose() { + const { sidecar, helper } = await startAuthenticatedSession() + try { + const startedAt = performance.now() + sidecar.child.disconnect() + if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) { + throw new Error('Sidecar did not exit after graceful IPC close') + } + const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS) + if (helperExitMs === null) { + throw new Error('Helper did not exit after graceful owner close') + } + return Math.round(performance.now() - startedAt) + } finally { + sidecar.child.kill('SIGKILL') + await stopProcess(helper) + } +} + +async function runInternalTrial(expectation) { + let sidecar + let helper + let invalidPeer + let invalidPeerRejected = false + try { + const session = await startAuthenticatedSession() + sidecar = session.sidecar + helper = session.helper + const authenticatedAt = performance.now() + const initial = await sampleProcess(helper.pid) + const activeRequests = await exerciseActiveRequests(sidecar) + const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt)) + await sleep(remainingHoldMs) + const connected = await sampleProcess(helper.pid) + const invalidSocketPath = socketPathFromCommand(helper.command) + invalidPeer = await connectInvalidPeer(invalidSocketPath) + invalidPeerRejected = true + const survivedClaimDeadline = + performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid) + + sidecar.child.kill('SIGKILL') + await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS) + const abruptExitMs = await waitForProcessExit( + helper, + expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS + ) + const helperExitedAfterAbruptLoss = abruptExitMs !== null + if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) { + throw new Error('Expected helper to exit after abrupt authenticated owner loss') + } + if (expectation === 'retained' && helperExitedAfterAbruptLoss) { + throw new Error('Expected baseline helper to remain after abrupt owner loss') + } + const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes + await stopProcess(helper) + helper = null + invalidPeer.destroy() + invalidPeer = null + + const gracefulExitMs = await verifyGracefulClose() + return { + authenticated: session.authenticated, + survivedClaimDeadline, + invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss, + connectedRssBytes: connected.rssBytes, + connectedCpuMilliseconds: Math.max( + 0, + Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000) + ), + activeRequests, + cpuSampleMs: Math.round(performance.now() - authenticatedAt), + helperExitedAfterAbruptLoss, + abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs), + postLossRssBytes, + gracefulExitMs + } + } finally { + invalidPeer?.destroy() + sidecar?.child.kill('SIGKILL') + await stopProcess(helper) + } +} + +function parseArgs(argv) { + const options = { expect: '', trials: DEFAULT_TRIALS, output: '' } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = argv[index + 1] + if (arg === '--expect' || arg === '--trials' || arg === '--output') { + if (!value) { + throw new Error(`Missing value for ${arg}`) + } + options[arg.slice(2)] = arg === '--trials' ? Number(value) : value + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!['retained', 'reaped'].includes(options.expect)) { + throw new Error('--expect must be retained or reaped') + } + if (!Number.isInteger(options.trials) || options.trials < 1) { + throw new Error('--trials must be a positive integer') + } + return options +} + +function electronPath() { + const electronModulePath = fileURLToPath(import.meta.resolve('electron')) + return execFileSync( + process.execPath, + ['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`], + { encoding: 'utf8' } + ) +} + +function buildArtifacts() { + execFileSync('pnpm', ['exec', 'electron-vite', 'build'], { + cwd: repoRoot, + stdio: 'inherit' + }) + execFileSync('pnpm', ['build:computer-macos'], { + cwd: repoRoot, + stdio: 'inherit' + }) +} + +function artifactSha256(artifactPath) { + return createHash('sha256').update(readFileSync(artifactPath)).digest('hex') +} + +function runTrial(executable, expectation) { + let launcherDir + let trialTempDir + let helperRecordPath + let resultPath + let stderrPath + let stdoutPath + let result + let serializedResult + let parsedResult + let parsedResultAvailable = false + let trialError + let cleanupError + let stderrDescriptor + let stdoutDescriptor + let trialOutput = '' + try { + launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-')) + trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-')) + helperRecordPath = path.join(launcherDir, 'helper.json') + resultPath = path.join(launcherDir, 'result.json') + stderrPath = path.join(launcherDir, 'stderr.log') + stdoutPath = path.join(launcherDir, 'stdout.log') + writeFileSync( + path.join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' }) + ) + writeFileSync( + path.join(launcherDir, 'main.cjs'), + `import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + const env = { + ...process.env, + TMPDIR: trialTempDir, + [INTERNAL_ENV]: '1', + [EXPECTATION_ENV]: expectation, + [HELPER_RECORD_PATH_ENV]: helperRecordPath, + [RESULT_PATH_ENV]: resultPath + } + delete env.ELECTRON_RUN_AS_NODE + stderrDescriptor = openSync(stderrPath, 'w') + stdoutDescriptor = openSync(stdoutPath, 'w') + result = spawnBenchmarkProcess(executable, [launcherDir], { + cwd: repoRoot, + env, + stdio: ['ignore', stdoutDescriptor, stderrDescriptor], + timeout: TRIAL_TIMEOUT_MS + }) + if (result.status === 0 && existsSync(resultPath)) { + serializedResult = readFileSync(resultPath, 'utf8') + parsedResult = parseBenchmarkTrialResult(serializedResult) + parsedResultAvailable = true + } + } catch (error) { + trialError = error + } finally { + const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable) + const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined + const cleanup = cleanupOwnerLossTrial({ + failed: failedTrial, + pid: result?.pid, + marker: trialMarker, + recordPath: helperRecordPath, + helperPath, + tempDir: trialTempDir, + stderrDescriptor, + stdoutDescriptor, + outputPaths: [stderrPath, stdoutPath], + launcherDir + }) + cleanupError = cleanup.error + trialOutput = cleanup.output + } + if (!trialError && result?.status !== 0) { + trialError = new Error( + `Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}` + ) + } + if (!trialError && !serializedResult) { + trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`) + } + throwBenchmarkTrialFailures(trialError, cleanupError) + return parsedResult +} + +function runBenchmark() { + if (process.platform !== 'darwin') { + throw new Error('The computer-use helper owner benchmark is macOS-only') + } + const options = parseArgs(process.argv.slice(2)) + const dirty = execFileSync('git', ['status', '--porcelain'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim() + if (dirty) { + throw new Error('Commit or stash changes before running the provenance-bound benchmark') + } + buildArtifacts() + if (!existsSync(sidecarPath) || !existsSync(helperPath)) { + throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts') + } + const executable = electronPath() + const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect)) + const rssBytes = results.map((result) => result.connectedRssBytes) + const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds) + const activeRequestTotals = results.map((result) => result.activeRequests.totalMs) + const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond) + const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs) + const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs) + const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs) + const postLossRssBytes = results.map((result) => result.postLossRssBytes) + const report = { + benchmark: 'macos-computer-helper-authenticated-owner-loss', + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(), + artifacts: { + sidecarSha256: artifactSha256(sidecarPath), + helperSha256: artifactSha256(helperPath) + }, + sources: { + benchmarkSha256: artifactSha256(scriptPath), + metricsSha256: artifactSha256(metricsPath), + processCleanupSha256: artifactSha256(processCleanupPath), + trialCleanupSha256: artifactSha256(trialCleanupPath) + }, + expectation: options.expect, + trials: options.trials, + ownerHoldMs: OWNER_HOLD_MS, + activeRequestCount: ACTIVE_REQUEST_COUNT, + authenticated: results.every((result) => result.authenticated), + survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline), + invalidPeerRejectedAndDidNotRetain: results.every( + (result) => result.invalidPeerRejectedAndDidNotRetain + ), + connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)), + connectedCpuMilliseconds: cpuMilliseconds, + medianConnectedCpuMilliseconds: median(cpuMilliseconds), + activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))), + medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)), + activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))), + medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)), + activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))), + medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)), + activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))), + medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)), + activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))), + helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss), + abruptExitMs: results.map((result) => result.abruptExitMs), + postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)), + gracefulExitMs: results.map((result) => result.gracefulExitMs) + } + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options.output) { + writeFileSync(path.resolve(options.output), serialized) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + const { app } = await import('electron') + await app.whenReady() + try { + const result = await runInternalTrial(process.env[EXPECTATION_ENV]) + writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result)) + } finally { + app.quit() + } +} else { + runBenchmark() +} diff --git a/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs b/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs new file mode 100644 index 00000000000..512a663cddb --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs' + +describe('macOS helper owner-loss benchmark group recovery', () => { + it('retains uncertain stop state across cleanup stage failures', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const groupState = { stopped: false } + let scanCount = 0 + let continueAttempts = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount >= 3) { + throw new Error( + scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed' + ) + } + return members + }, + signalProcess: (pid, signal) => { + if (pid === -41 && signal === 'SIGCONT') { + continueAttempts += 1 + if (continueAttempts === 1) { + throw new Error('first compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(groupState.stopped).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('final inspection failed') + expect(continueAttempts).toBe(2) + expect(groupState.stopped).toBe(false) + }) + + it('retains an uncertain anchor stop across cleanup stage failures', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const groupState = { stopped: false, anchorPid: null } + let scanCount = 0 + let continueAttempts = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount >= 2) { + throw new Error( + scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed' + ) + } + return members + }, + signalProcess: (pid, signal) => { + if (pid === 41 && signal === 'SIGCONT') { + continueAttempts += 1 + if (continueAttempts === 1) { + throw new Error('first anchor compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(groupState.anchorPid).toBe(41) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('final inspection failed') + expect(continueAttempts).toBe(2) + expect(groupState.anchorPid).toBeNull() + }) + + it('recovers a retained anchor before selecting a new one', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` } + const groupState = { stopped: false, anchorPid: null } + let scanCount = 0 + let firstAnchorContinues = 0 + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount === 2) { + throw new Error('post-anchor inspection failed') + } + return scanCount === 1 ? [firstAnchor] : [finalAnchor] + }, + signalProcess: (pid, signal) => { + if (pid === 41 && signal === 'SIGCONT') { + firstAnchorContinues += 1 + if (firstAnchorContinues === 1) { + throw new Error('first anchor compensation failed') + } + } + } + } + + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations) + ).toThrow('Benchmark process group signal recovery failed') + expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true) + expect(firstAnchorContinues).toBe(2) + expect(groupState).toEqual({ stopped: false, anchorPid: null }) + }) + + it('preserves group authority errors when compensation fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const ownershipError = 'Benchmark process group no longer belongs to this trial' + let thrown + + try { + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: true }, + { + processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }], + signalProcess: () => { + throw new Error('resume denied') + } + } + ) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied']) + }) +}) diff --git a/config/scripts/macos-computer-helper-owner-loss-metrics.mjs b/config/scripts/macos-computer-helper-owner-loss-metrics.mjs new file mode 100644 index 00000000000..b65a062ad34 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-metrics.mjs @@ -0,0 +1,62 @@ +import { execFileSync } from 'node:child_process' + +const SAMPLE_COUNT = 5 +const SAMPLE_INTERVAL_MS = 200 + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +export function percentile(values, fraction) { + const sorted = [...values].sort((left, right) => left - right) + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)] +} + +function parseCpuTimeSeconds(value) { + const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value] + const days = dayOrTime === null ? 0 : Number(dayOrTime) + const parts = clock.split(':').map(Number) + if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) { + throw new Error(`Invalid process CPU time: ${value}`) + } + const seconds = parts.pop() ?? 0 + const minutes = parts.pop() ?? 0 + const hours = parts.pop() ?? 0 + return days * 86_400 + hours * 3_600 + minutes * 60 + seconds +} + +export function processSnapshot(pid) { + const raw = execFileSync( + 'ps', + ['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)], + { encoding: 'utf8' } + ).trim() + const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/) + if (!match) { + throw new Error(`Could not inspect process ${pid}: ${raw}`) + } + return { + rssBytes: Number(match[1]) * 1024, + cpuTimeSeconds: parseCpuTimeSeconds(match[2]), + command: match[3] + } +} + +export async function sampleProcess(pid) { + const samples = [] + for (let index = 0; index < SAMPLE_COUNT; index += 1) { + samples.push(processSnapshot(pid)) + await sleep(SAMPLE_INTERVAL_MS) + } + return { + rssBytes: median(samples.map((sample) => sample.rssBytes)), + cpuTimeSeconds: samples.at(-1).cpuTimeSeconds, + command: samples.at(-1).command + } +} diff --git a/config/scripts/macos-computer-helper-owner-loss-processes.mjs b/config/scripts/macos-computer-helper-owner-loss-processes.mjs new file mode 100644 index 00000000000..952d726d28d --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-processes.mjs @@ -0,0 +1,418 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' + +const PROCESS_EXIT_TIMEOUT_MS = 2_000 +const PROCESS_POLL_MS = 25 +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) + +const processIdentityOperations = { + executePs: execFileSync, + signalProcess: process.kill.bind(process) +} + +export function processIdentity(pid, operations = processIdentityOperations) { + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + try { + const output = operations + .executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], { + encoding: 'utf8' + }) + .trim() + const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/) + if (!match) { + throw new Error(`Could not parse process identity for ${pid}`) + } + return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] } + } catch (error) { + try { + operations.signalProcess(pid, 0) + } catch (lookupError) { + if (lookupError?.code === 'ESRCH') { + return null + } + } + throw error + } +} + +function matchingDetachedProcesses(identities, expectedCommandFragments) { + return identities.filter( + (identity) => + identity.pgid === identity.pid && + expectedCommandFragments.every((fragment) => identity.command.includes(fragment)) + ) +} + +const matchingProcessOperations = { + processIdentities, + signalProcessIdentity, + waitForIdentityExit +} + +export function killProcessMatchingCommand( + expectedCommandFragments, + operations = matchingProcessOperations +) { + const matches = matchingDetachedProcesses( + operations.processIdentities(), + expectedCommandFragments + ) + if (matches.length === 0) { + return false + } + const errors = [] + for (const match of matches) { + try { + if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) { + operations.waitForIdentityExit(match) + } + } catch (error) { + errors.push(error) + } + } + try { + const remaining = matchingDetachedProcesses( + operations.processIdentities(), + expectedCommandFragments + ) + if (remaining.length > 0) { + errors.push( + new Error( + `Benchmark helper cleanup left matching processes: ${remaining + .map((identity) => identity.pid) + .join(', ')}` + ) + ) + } + } catch (error) { + errors.push(error) + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark exact-command cleanup failed') + } + return true +} + +function sleepSync(milliseconds) { + Atomics.wait(sleepBuffer, 0, 0, milliseconds) +} + +function validateDetachedIdentity(identity, expectedCommandFragment) { + if ( + !Number.isInteger(identity?.pid) || + identity.pid <= 0 || + identity.pgid !== identity.pid || + typeof identity.command !== 'string' || + !identity.command.includes(expectedCommandFragment) + ) { + throw new Error('Recorded benchmark helper identity is invalid') + } +} + +function sameIdentity(left, right) { + return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command +} + +function processIdentities(includeEnvironment = false) { + const args = includeEnvironment + ? ['eww', '-axo', 'pid=,pgid=,command='] + : ['-axo', 'pid=,pgid=,command='] + const output = execFileSync('ps', args, { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024 + }) + return output + .split('\n') + .map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)) + .filter(Boolean) + .map((match) => ({ + pid: Number(match[1]), + pgid: Number(match[2]), + command: match[3] + })) +} + +function waitForIdentityExit(identity) { + const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS + while (Date.now() < deadline) { + if (!processIdentityIsCurrent(identity)) { + return true + } + sleepSync(PROCESS_POLL_MS) + } + throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`) +} + +export function spawnBenchmarkProcess(executable, args, options) { + return spawnSync(executable, args, { + ...options, + detached: true, + killSignal: 'SIGKILL' + }) +} + +export function runBenchmarkCleanupStages(stages) { + const errors = [] + for (const stage of stages) { + try { + stage() + } catch (error) { + errors.push(error) + } + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark trial cleanup failed') + } +} + +export function throwBenchmarkTrialFailures(trialError, cleanupError) { + if (trialError && cleanupError) { + throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed') + } + if (trialError) { + throw trialError + } + if (cleanupError) { + throw cleanupError + } +} + +export function parseBenchmarkTrialResult(serializedResult) { + return JSON.parse(serializedResult) +} + +export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) { + return spawnResult?.status !== 0 || !parsedResultAvailable +} + +const processGroupSignalOperations = { + processIdentities, + signalProcess: process.kill.bind(process) +} + +function compensateStoppedGroup(pgid, groupState, operations) { + const errors = [] + const targets = [ + groupState.stopped ? [-pgid, 'stopped'] : null, + groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null + ].filter(Boolean) + for (const [pid, stateKey] of targets) { + try { + operations.signalProcess(pid, 'SIGCONT') + groupState[stateKey] = stateKey === 'stopped' ? false : null + } catch (error) { + if (error?.code === 'ESRCH') { + groupState[stateKey] = stateKey === 'stopped' ? false : null + } else { + errors.push(error) + } + } + } + return errors +} + +export function signalValidatedProcessGroup( + pgid, + environmentFragment, + signal, + groupState = { stopped: false, anchorPid: null }, + operations = processGroupSignalOperations +) { + if (!Number.isInteger(pgid) || pgid <= 0) { + return false + } + let members + try { + members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid) + } catch (error) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [error, ...recoveryErrors], + 'Benchmark process group recovery failed before validation' + ) + } + throw error + } + if (members.length === 0) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed') + } + return false + } + if (members.some((identity) => !identity.command.includes(environmentFragment))) { + const ownershipError = new Error('Benchmark process group no longer belongs to this trial') + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [ownershipError, ...recoveryErrors], + 'Benchmark process group authority recovery failed' + ) + } + throw ownershipError + } + if (groupState.anchorPid) { + try { + operations.signalProcess(groupState.anchorPid, 'SIGCONT') + groupState.anchorPid = null + } catch (error) { + if (error?.code === 'ESRCH') { + groupState.anchorPid = null + } else { + throw new AggregateError([error], 'Benchmark pending anchor recovery failed') + } + } + } + const anchor = members[0] + try { + operations.signalProcess(anchor.pid, 'SIGSTOP') + groupState.anchorPid = anchor.pid + const stoppedAnchor = operations + .processIdentities(true) + .find((identity) => identity.pid === anchor.pid) + if (!sameIdentity(stoppedAnchor, anchor)) { + throw new Error('Benchmark process group anchor changed before signaling') + } + operations.signalProcess(-pgid, 'SIGSTOP') + groupState.stopped = true + groupState.anchorPid = null + const stoppedMembers = operations + .processIdentities(true) + .filter((identity) => identity.pgid === pgid) + if ( + stoppedMembers.length === 0 || + stoppedMembers.some((identity) => !identity.command.includes(environmentFragment)) + ) { + throw new Error('Benchmark process group changed before signaling') + } + if (signal !== 'SIGSTOP') { + operations.signalProcess(-pgid, signal) + if (signal !== 'SIGKILL') { + operations.signalProcess(-pgid, 'SIGCONT') + } + groupState.stopped = false + groupState.anchorPid = null + } + return true + } catch (error) { + const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations) + if (recoveryErrors.length > 0) { + throw new AggregateError( + [error, ...recoveryErrors], + 'Benchmark process group signal recovery failed' + ) + } + if (error.code === 'ESRCH') { + return false + } + throw error + } +} + +export function writeProcessRecord(recordPath, processIdentity) { + const temporaryPath = `${recordPath}.${process.pid}.tmp` + writeFileSync(temporaryPath, JSON.stringify(processIdentity)) + renameSync(temporaryPath, recordPath) +} + +export function processIdentityIsCurrent(identity) { + return sameIdentity(processIdentity(identity?.pid), identity) +} + +const processSignalOperations = { + processIdentity, + signalProcess: process.kill.bind(process) +} + +export function signalProcessIdentity( + identity, + expectedCommandFragment, + signal, + operations = processSignalOperations +) { + validateDetachedIdentity(identity, expectedCommandFragment) + const currentIdentity = operations.processIdentity(identity.pid) + if (!currentIdentity) { + return false + } + if (!sameIdentity(currentIdentity, identity)) { + throw new Error('Recorded benchmark helper PID now belongs to another process') + } + let stopped = false + try { + operations.signalProcess(identity.pid, 'SIGSTOP') + stopped = true + const stoppedIdentity = operations.processIdentity(identity.pid) + if (!sameIdentity(stoppedIdentity, identity)) { + throw new Error('Recorded benchmark helper PID changed before signaling') + } + operations.signalProcess(-identity.pgid, signal) + if (signal !== 'SIGKILL') { + operations.signalProcess(-identity.pgid, 'SIGCONT') + } + stopped = false + return true + } catch (error) { + let resumeError + if (stopped) { + try { + operations.signalProcess(identity.pid, 'SIGCONT') + } catch (caught) { + if (caught?.code !== 'ESRCH') { + resumeError = caught + } + } + } + if (resumeError) { + throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed') + } + if (error.code === 'ESRCH') { + return false + } + throw error + } +} + +export function killRecordedProcess(recordPath, expectedCommandFragment) { + if (!existsSync(recordPath)) { + return false + } + const record = JSON.parse(readFileSync(recordPath, 'utf8')) + if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) { + return false + } + return waitForIdentityExit(record) +} + +export function killRecordedAndMatchingProcesses( + recordPath, + recordedCommandFragment, + matchingCommandFragments +) { + const errors = [] + try { + killRecordedProcess(recordPath, recordedCommandFragment) + } catch (error) { + errors.push(error) + } + try { + killProcessMatchingCommand(matchingCommandFragments) + } catch (error) { + errors.push(error) + } + if (errors.length === 1) { + throw errors[0] + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Benchmark helper cleanup failed') + } +} diff --git a/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs b/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs new file mode 100644 index 00000000000..764eb73009f --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-processes.test.mjs @@ -0,0 +1,558 @@ +import { execFileSync, spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + benchmarkTrialNeedsCleanup, + killProcessMatchingCommand, + killRecordedAndMatchingProcesses, + killRecordedProcess, + parseBenchmarkTrialResult, + processIdentity, + runBenchmarkCleanupStages, + signalProcessIdentity, + signalValidatedProcessGroup, + spawnBenchmarkProcess, + throwBenchmarkTrialFailures, + writeProcessRecord +} from './macos-computer-helper-owner-loss-processes.mjs' +import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs' + +const describeMacOS = process.platform === 'darwin' ? describe : describe.skip +const spawnedPids = new Set() +const temporaryDirectories = new Set() + +afterEach(() => { + for (const pid of spawnedPids) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + spawnedPids.clear() + for (const temporaryDirectory of temporaryDirectories) { + rmSync(temporaryDirectory, { recursive: true, force: true }) + } + temporaryDirectories.clear() +}) + +describeMacOS('macOS helper owner-loss benchmark process cleanup', () => { + it('enforces a hard timeout when the trial ignores SIGTERM', () => { + const startedAt = Date.now() + const result = spawnBenchmarkProcess( + process.execPath, + ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"], + { stdio: 'ignore', timeout: 100 } + ) + expect(result.error?.code).toBe('ETIMEDOUT') + expect(result.signal).toBe('SIGKILL') + expect(Date.now() - startedAt).toBeLessThan(2_000) + expect(() => process.kill(result.pid, 0)).toThrow() + }) + + it('runs every cleanup stage before aggregating errors', () => { + const completed = [] + let thrown + + try { + runBenchmarkCleanupStages([ + () => { + completed.push(1) + throw new Error('first failure') + }, + () => { + completed.push(2) + }, + () => { + completed.push(3) + throw new Error('last failure') + } + ]) + } catch (error) { + thrown = error + } + expect(completed).toEqual([1, 2, 3]) + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure']) + }) + + it('preserves malformed-result and cleanup failures', () => { + let trialError + try { + parseBenchmarkTrialResult('{malformed') + } catch (error) { + trialError = error + } + const cleanupError = new Error('cleanup failed') + let thrown + + try { + throwBenchmarkTrialFailures(trialError, cleanupError) + } catch (error) { + thrown = error + } + expect(trialError).toBeInstanceOf(SyntaxError) + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors).toEqual([trialError, cleanupError]) + }) + + it('cleans up a status-zero trial whose result could not be parsed', () => { + expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true) + expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true) + expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false) + expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true) + }) + + it('removes a launcher directory after partial trial setup', () => { + const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-')) + temporaryDirectories.add(launcherDir) + + const cleanup = cleanupOwnerLossTrial({ + failed: true, + launcherDir, + outputPaths: [] + }) + + expect(cleanup.error).toBeUndefined() + expect(existsSync(launcherDir)).toBe(false) + temporaryDirectories.delete(launcherDir) + }) + + it('kills a timed-out trial group only after validating its environment', async () => { + const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-')) + temporaryDirectories.add(temporaryDirectory) + const childPidPath = path.join(temporaryDirectory, 'child.pid') + const environmentName = `ORCA_OWNER_GROUP_${process.pid}` + const environmentValue = `${Date.now()}` + const fixture = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + stdio: 'ignore' + }) + writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid)) + setInterval(() => {}, 1000) + ` + const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], { + env: { ...process.env, [environmentName]: environmentValue }, + stdio: 'ignore', + timeout: 100 + }) + const childPid = Number(readFileSync(childPidPath, 'utf8')) + spawnedPids.add(childPid) + const environmentFragment = `${environmentName}=${environmentValue}` + const groupState = { stopped: false } + + expect(() => + signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP') + ).toThrow('Benchmark process group no longer belongs to this trial') + expect(() => process.kill(childPid, 0)).not.toThrow() + expect( + signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState) + ).toBe(true) + expect( + signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState) + ).toBe(true) + await expect + .poll(() => { + try { + process.kill(childPid, 0) + return true + } catch { + return false + } + }) + .toBe(false) + + spawnedPids.delete(childPid) + }) + + it('resumes the group after post-stop revalidation fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [ + { pid: 41, pgid: 41, command: `/launcher ${marker}` }, + { pid: 42, pgid: 41, command: `/child ${marker}` } + ] + const signals = [] + let scanCount = 0 + + expect(() => + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: false }, + { + processIdentities: () => { + scanCount += 1 + if (scanCount === 3) { + throw new Error('transient group inspection failure') + } + return members + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + ) + ).toThrow('transient group inspection failure') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [-41, 'SIGSTOP'], + [-41, 'SIGCONT'] + ]) + }) + + it('compensates a possible stop after group anchor replacement', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const replacement = { pid: 41, pgid: 99, command: '/unrelated' } + const signals = [] + let scanCount = 0 + + expect(() => + signalValidatedProcessGroup( + 41, + marker, + 'SIGKILL', + { stopped: false }, + { + processIdentities: () => { + scanCount += 1 + return scanCount === 1 ? [anchor] : [replacement] + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + ) + ).toThrow('Benchmark process group anchor changed before signaling') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('resumes a previously frozen group when final inspection fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }] + const signals = [] + let scanCount = 0 + const groupState = { stopped: false } + const operations = { + processIdentities: () => { + scanCount += 1 + if (scanCount === 4) { + throw new Error('transient final inspection failure') + } + return members + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('transient final inspection failure') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [-41, 'SIGSTOP'], + [-41, 'SIGCONT'] + ]) + }) + + it('resumes a previously frozen group when final anchor stop fails', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const members = [ + { pid: 41, pgid: 41, command: `/launcher ${marker}` }, + { pid: 42, pgid: 41, command: `/child ${marker}` } + ] + const signals = [] + let finalCall = false + const groupState = { stopped: false } + const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' }) + const operations = { + processIdentities: () => members, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + if (finalCall && pid === 41 && signal === 'SIGSTOP') { + throw missingProcessError + } + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + finalCall = true + expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false) + expect(signals.at(-1)).toEqual([-41, 'SIGCONT']) + }) + + it('resumes a previously frozen group after final anchor replacement', () => { + const marker = 'ORCA_OWNER_GROUP=trial' + const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` } + const child = { pid: 42, pgid: 41, command: `/child ${marker}` } + const replacement = { pid: 41, pgid: 99, command: '/unrelated' } + const signals = [] + let scanCount = 0 + const groupState = { stopped: false } + const operations = { + processIdentities: () => { + scanCount += 1 + return scanCount === 5 ? [replacement, child] : [anchor, child] + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + } + + expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true) + expect(() => + signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations) + ).toThrow('Benchmark process group anchor changed before signaling') + expect(signals.slice(-2)).toEqual([ + [-41, 'SIGCONT'], + [41, 'SIGCONT'] + ]) + }) + + it('kills a recorded helper in a separate process group', async () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const exited = new Promise((resolve) => helper.once('exit', resolve)) + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + const processGroup = Number( + execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], { + encoding: 'utf8' + }).trim() + ) + writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command }) + + expect(processGroup).toBe(helper.pid) + expect(killRecordedProcess(recordPath, marker)).toBe(true) + await exited + expect(() => process.kill(helper.pid, 0)).toThrow() + + spawnedPids.delete(helper.pid) + }) + + it('kills every unrecorded helper using its unique trial command', async () => { + const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}` + const helpers = Array.from({ length: 2 }, () => + spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + ) + for (const helper of helpers) { + spawnedPids.add(helper.pid) + helper.unref() + } + const exited = Promise.all( + helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve))) + ) + + expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true) + await exited + for (const helper of helpers) { + expect(() => process.kill(helper.pid, 0)).toThrow() + spawnedPids.delete(helper.pid) + } + }) + + it('continues exact-match cleanup after an earlier match fails', () => { + const marker = `orca-owner-multiple-${process.pid}-${Date.now()}` + const matches = [ + { pid: 41, pgid: 41, command: `/helper ${marker}` }, + { pid: 42, pgid: 42, command: `/helper ${marker}` } + ] + const attempted = [] + let scanCount = 0 + + expect(() => + killProcessMatchingCommand(['/helper', marker], { + processIdentities: () => { + scanCount += 1 + return scanCount === 1 ? matches : [matches[0]] + }, + signalProcessIdentity: (identity) => { + attempted.push(identity.pid) + if (identity.pid === matches[0].pid) { + throw new Error('identity changed') + } + return true + }, + waitForIdentityExit: () => {} + }) + ).toThrow('Benchmark exact-command cleanup failed') + expect(attempted).toEqual([41, 42]) + }) + + it('does not treat an identity query failure as process exit', () => { + const queryError = new Error('transient ps failure') + + expect(() => + processIdentity(41, { + executePs: () => { + throw queryError + }, + signalProcess: () => {} + }) + ).toThrow(queryError) + }) + + it('resumes a helper when post-stop identity inspection fails', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const signals = [] + let inspectionCount = 0 + + expect(() => + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + if (inspectionCount === 2) { + throw new Error('transient ps failure after stop') + } + return identity + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + }) + ).toThrow('transient ps failure after stop') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('compensates a possible stop after helper PID replacement', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const replacement = { pid: 41, pgid: 41, command: '/unrelated' } + const signals = [] + let inspectionCount = 0 + + expect(() => + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + return inspectionCount === 1 ? identity : replacement + }, + signalProcess: (pid, signal) => { + signals.push([pid, signal]) + } + }) + ).toThrow('Recorded benchmark helper PID changed before signaling') + expect(signals).toEqual([ + [41, 'SIGSTOP'], + [41, 'SIGCONT'] + ]) + }) + + it('preserves helper identity errors when compensation fails', () => { + const identity = { pid: 41, pgid: 41, command: '/helper marker' } + const replacement = { pid: 41, pgid: 41, command: '/unrelated' } + let inspectionCount = 0 + let thrown + + try { + signalProcessIdentity(identity, 'marker', 'SIGKILL', { + processIdentity: () => { + inspectionCount += 1 + return inspectionCount === 1 ? identity : replacement + }, + signalProcess: (_pid, signal) => { + if (signal === 'SIGCONT') { + throw new Error('resume denied') + } + } + }) + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(AggregateError) + expect(thrown.errors.map((error) => error.message)).toEqual([ + 'Recorded benchmark helper PID changed before signaling', + 'resume denied' + ]) + }) + + it('treats a missing PID as process exit after an identity query failure', () => { + const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' }) + + expect( + processIdentity(41, { + executePs: () => { + throw new Error('ps found no process') + }, + signalProcess: () => { + throw missingProcessError + } + }) + ).toBeNull() + }) + + it('runs unique-command cleanup after an invalid process record', async () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + detached: true, + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const exited = new Promise((resolve) => helper.once('exit', resolve)) + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command }) + + expect(() => + killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker]) + ).toThrow('Recorded benchmark helper identity is invalid') + await exited + expect(() => process.kill(helper.pid, 0)).toThrow() + + spawnedPids.delete(helper.pid) + }) + + it('rejects a record that is not a detached process-group identity', () => { + const temporaryDirectory = mkdtempSync( + path.join(tmpdir(), 'orca-owner-benchmark-identity-test-') + ) + temporaryDirectories.add(temporaryDirectory) + const recordPath = path.join(temporaryDirectory, 'helper.json') + const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}` + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], { + stdio: 'ignore' + }) + spawnedPids.add(helper.pid) + helper.unref() + const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], { + encoding: 'utf8' + }).trim() + writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command }) + + expect(() => killRecordedProcess(recordPath, marker)).toThrow( + 'Recorded benchmark helper identity is invalid' + ) + expect(() => process.kill(helper.pid, 0)).not.toThrow() + }) +}) diff --git a/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs b/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs new file mode 100644 index 00000000000..c3fb4aaf384 --- /dev/null +++ b/config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs @@ -0,0 +1,63 @@ +import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { + killRecordedAndMatchingProcesses, + runBenchmarkCleanupStages, + signalValidatedProcessGroup +} from './macos-computer-helper-owner-loss-processes.mjs' + +export function cleanupOwnerLossTrial(options) { + const groupState = { stopped: false, anchorPid: null } + let error + let output = '' + try { + runBenchmarkCleanupStages([ + () => { + if (options.failed && Number.isInteger(options.pid) && options.marker) { + signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState) + } + }, + () => { + if (options.failed && options.recordPath && options.tempDir) { + killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [ + options.helperPath, + options.tempDir + ]) + } + }, + () => { + if (options.failed && Number.isInteger(options.pid) && options.marker) { + signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState) + } + }, + () => { + if (options.stderrDescriptor !== undefined) { + closeSync(options.stderrDescriptor) + } + }, + () => { + if (options.stdoutDescriptor !== undefined) { + closeSync(options.stdoutDescriptor) + } + }, + () => { + output = (options.outputPaths ?? []) + .filter((outputPath) => outputPath && existsSync(outputPath)) + .map((outputPath) => readFileSync(outputPath, 'utf8')) + .join('') + }, + () => { + if (options.launcherDir) { + rmSync(options.launcherDir, { recursive: true, force: true }) + } + }, + () => { + if (options.tempDir) { + rmSync(options.tempDir, { recursive: true, force: true }) + } + } + ]) + } catch (caught) { + error = caught + } + return { error, output } +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 8edeb06131b..50ecae7d024 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -2579,9 +2579,12 @@ private enum KeyMap { } private final class AgentRuntime: NSObject, NSApplicationDelegate { + private static let unclaimedSessionDeadline: TimeInterval = 30 + private let socketPath: String private let token: String? private var listener: SocketListener? + private var unclaimedSessionTimeout: DispatchWorkItem? init(socketPath: String, token: String?) { self.socketPath = socketPath @@ -2590,9 +2593,29 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { do { - let listener = try SocketListener(socketPath: socketPath, token: token) + let timeout = DispatchWorkItem { + fputs("computer-use agent received no authenticated session before its deadline\n", stderr) + NSApp.terminate(nil) + } + unclaimedSessionTimeout = timeout + let listener = try SocketListener( + socketPath: socketPath, + token: token, + onSessionClaimed: { + timeout.cancel() + }, + onSessionClosed: { + DispatchQueue.main.async { + NSApp.terminate(nil) + } + } + ) self.listener = listener listener.start() + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.unclaimedSessionDeadline, + execute: timeout + ) } catch { fputs("failed to start computer-use socket: \(error)\n", stderr) NSApp.terminate(nil) @@ -2600,6 +2623,8 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + unclaimedSessionTimeout?.cancel() + unclaimedSessionTimeout = nil listener?.stop() } } @@ -3505,14 +3530,26 @@ private final class ButtonTarget: NSObject { private final class SocketListener: @unchecked Sendable { private let socketPath: String private let token: String? + private let onSessionClaimed: () -> Void + private let onSessionClosed: () -> Void private let provider = Provider() private let providerLock = NSLock() + private let sessionLock = NSLock() + private var sessionOwnership = AgentSessionOwnership() + private var lastConnectionID: UInt64 = 0 private var socketFd: Int32 = -1 private var isStopped = false - init(socketPath: String, token: String?) throws { + init( + socketPath: String, + token: String?, + onSessionClaimed: @escaping () -> Void, + onSessionClosed: @escaping () -> Void + ) throws { self.socketPath = socketPath self.token = token + self.onSessionClaimed = onSessionClaimed + self.onSessionClosed = onSessionClosed try bindSocket() } @@ -3587,14 +3624,35 @@ private final class SocketListener: @unchecked Sendable { } continue } + guard let connectionID = allocateConnectionID() else { + fputs("computer-use socket exhausted connection identities\n", stderr) + close(fd) + continue + } Thread.detachNewThread { [weak self] in - self?.handleConnection(fd) + self?.handleConnection(fd, connectionID: connectionID) } } } - private func handleConnection(_ fd: Int32) { - defer { close(fd) } + private func allocateConnectionID() -> AgentSessionConnectionID? { + sessionLock.lock() + defer { sessionLock.unlock() } + guard lastConnectionID < UInt64.max else { return nil } + lastConnectionID += 1 + return AgentSessionConnectionID(rawValue: lastConnectionID) + } + + private func handleConnection(_ fd: Int32, connectionID: AgentSessionConnectionID) { + var registeredSession = false + var hangupMonitor: AuthenticatedConnectionHangupMonitor? + defer { + hangupMonitor?.cancel() + if registeredSession { + disconnectSession(connectionID) + } + close(fd) + } let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true let decoder = JSONDecoder() while let line = readLine(from: fd) { @@ -3603,6 +3661,40 @@ private final class SocketListener: @unchecked Sendable { else { continue } + if !registeredSession && isAuthenticatedAgentSession( + expectedToken: token, + requestToken: request.token, + authorizedPeer: authorizedPeer + ) { + let monitor: AuthenticatedConnectionHangupMonitor + do { + monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: fd, + onHangup: { [weak self] in + self?.disconnectSession(connectionID) + } + ) + } catch { + fputs("computer-use owner monitor failed: \(error)\n", stderr) + return + } + sessionLock.lock() + let registration = sessionOwnership.registerConnection( + connectionID, + authenticated: true + ) + sessionLock.unlock() + guard registration != .rejected else { + monitor.cancel() + return + } + registeredSession = true + hangupMonitor = monitor + monitor.start() + if registration == .claimed { + onSessionClaimed() + } + } let response = handleRequest( provider: provider, lock: providerLock, @@ -3613,6 +3705,15 @@ private final class SocketListener: @unchecked Sendable { writeJSON(response, to: fd) } } + + private func disconnectSession(_ connectionID: AgentSessionConnectionID) { + sessionLock.lock() + let shouldTerminate = sessionOwnership.disconnect(connectionID) + sessionLock.unlock() + if shouldTerminate { + onSessionClosed() + } + } } private func existingPathMode(_ path: String) -> mode_t? { diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift new file mode 100644 index 00000000000..d39543e9895 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AgentSessionOwnership.swift @@ -0,0 +1,50 @@ +public struct AgentSessionConnectionID: Hashable, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } +} + +public enum AgentSessionRegistration: Sendable { + case rejected + case claimed + case retained +} + +public struct AgentSessionOwnership: Sendable { + private var authenticatedConnections: Set = [] + private var wasClaimed = false + private var sessionClosed = false + + public init() {} + + public mutating func registerConnection( + _ connection: AgentSessionConnectionID, + authenticated: Bool + ) -> AgentSessionRegistration { + guard authenticated, !sessionClosed else { return .rejected } + let inserted = authenticatedConnections.insert(connection).inserted + guard inserted else { return .rejected } + guard !wasClaimed else { return .retained } + wasClaimed = true + return .claimed + } + + public mutating func disconnect(_ connection: AgentSessionConnectionID) -> Bool { + guard authenticatedConnections.remove(connection) != nil else { return false } + guard wasClaimed, authenticatedConnections.isEmpty else { return false } + sessionClosed = true + return true + } +} + +public func isAuthenticatedAgentSession( + expectedToken: String?, + requestToken: String?, + authorizedPeer: Bool +) -> Bool { + guard authorizedPeer else { return false } + guard let expectedToken else { return true } + return requestToken == expectedToken +} diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift new file mode 100644 index 00000000000..55e4ec91721 --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/AuthenticatedConnectionHangupMonitor.swift @@ -0,0 +1,182 @@ +import Darwin +import Dispatch +import Foundation + +public final class AuthenticatedConnectionHangupMonitor: @unchecked Sendable { + typealias RegisterEvents = @Sendable ( + Int32, + UnsafePointer?, + Int32 + ) -> POSIXErrorCode? + typealias CloseDescriptor = @Sendable (Int32) -> Void + + private static let cancelEventIdentifier: UInt = 1 + + private let closeDescriptor: CloseDescriptor + private let eventQueue: DispatchQueue + private let eventQueueDescriptor: Int32 + private let onEvent: @Sendable () -> Void + private let stateLock = NSLock() + private let onHangup: @Sendable () -> Void + private var isCancelled = false + private var didReportHangup = false + private var isFinished = false + private var isStarted = false + + public convenience init( + fileDescriptor: Int32, + queue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-owner-hangup" + ), + onEvent: @escaping @Sendable () -> Void = {}, + onHangup: @escaping @Sendable () -> Void + ) throws { + try self.init( + fileDescriptor: fileDescriptor, + queue: queue, + registerEvents: { descriptor, events, count in + guard kevent(descriptor, events, count, nil, 0, nil) == 0 else { + return POSIXErrorCode(rawValue: errno) ?? .EIO + } + return nil + }, + closeDescriptor: { descriptor in + close(descriptor) + }, + onEvent: onEvent, + onHangup: onHangup + ) + } + + init( + fileDescriptor: Int32, + queue: DispatchQueue = DispatchQueue( + label: "com.stablyai.orca.computer-use-owner-hangup" + ), + registerEvents: RegisterEvents, + closeDescriptor: @escaping CloseDescriptor, + onEvent: @escaping @Sendable () -> Void = {}, + onHangup: @escaping @Sendable () -> Void + ) throws { + self.closeDescriptor = closeDescriptor + eventQueue = queue + self.onEvent = onEvent + self.onHangup = onHangup + let descriptor = kqueue() + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + guard fileDescriptor >= 0 else { + closeDescriptor(descriptor) + throw POSIXError(.EBADF) + } + eventQueueDescriptor = descriptor + var registrations = [ + kevent( + ident: UInt(fileDescriptor), + filter: Int16(EVFILT_READ), + flags: UInt16(EV_ADD | EV_CLEAR), + fflags: UInt32(NOTE_LOWAT), + data: Int.max, + udata: nil + ), + kevent( + ident: Self.cancelEventIdentifier, + filter: Int16(EVFILT_USER), + flags: UInt16(EV_ADD | EV_CLEAR), + fflags: 0, + data: 0, + udata: nil + ) + ] + let registrationError = registrations.withUnsafeMutableBufferPointer { buffer in + registerEvents(descriptor, buffer.baseAddress, Int32(buffer.count)) + } + if let registrationError { + isFinished = true + closeDescriptor(descriptor) + throw POSIXError(registrationError) + } + } + + deinit { + cancel() + } + + public func start() { + stateLock.lock() + guard !isStarted, !isCancelled, !isFinished else { + stateLock.unlock() + return + } + isStarted = true + stateLock.unlock() + eventQueue.async { [self] in + waitForHangup() + } + } + + public func cancel() { + stateLock.lock() + guard !isFinished else { + stateLock.unlock() + return + } + isCancelled = true + guard isStarted else { + isFinished = true + closeDescriptor(eventQueueDescriptor) + stateLock.unlock() + return + } + var event = kevent( + ident: Self.cancelEventIdentifier, + filter: Int16(EVFILT_USER), + flags: 0, + fflags: UInt32(NOTE_TRIGGER), + data: 0, + udata: nil + ) + _ = kevent(eventQueueDescriptor, &event, 1, nil, 0, nil) + stateLock.unlock() + } + + private func waitForHangup() { + defer { finish() } + while true { + var event = kevent() + let result = kevent(eventQueueDescriptor, nil, 0, &event, 1, nil) + if result < 0 && errno == EINTR { + continue + } + guard result > 0 else { return } + onEvent() + if event.filter == Int16(EVFILT_USER) { + return + } + if event.flags & UInt16(EV_EOF | EV_ERROR) != 0 { + reportHangup() + return + } + } + } + + private func finish() { + stateLock.lock() + isFinished = true + closeDescriptor(eventQueueDescriptor) + stateLock.unlock() + } + + private func reportHangup() { + stateLock.lock() + guard !isCancelled, !didReportHangup else { + stateLock.unlock() + return + } + didReportHangup = true + stateLock.unlock() + + onHangup() + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift new file mode 100644 index 00000000000..8094501c919 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AgentSessionOwnershipTests.swift @@ -0,0 +1,104 @@ +import OrcaComputerUseMacOSCore +import XCTest + +final class AgentSessionOwnershipTests: XCTestCase { + func testUnclaimedDisconnectDoesNotTerminateAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertFalse(ownership.disconnect(connection(12))) + } + + func testUnauthenticatedConnectionCannotClaimOrRetainAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: false), .rejected) + XCTAssertFalse(ownership.disconnect(connection(12))) + } + + func testLastAuthenticatedDisconnectTerminatesAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertTrue(ownership.disconnect(connection(12))) + } + + func testAgentWaitsForEveryAuthenticatedConnectionToClose() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .retained) + XCTAssertFalse(ownership.disconnect(connection(12))) + XCTAssertTrue(ownership.disconnect(connection(13))) + } + + func testDuplicateRegistrationDoesNotRetainAgent() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .rejected) + XCTAssertTrue(ownership.disconnect(connection(12))) + } + + func testClosedSessionRejectsNewConnections() { + var ownership = AgentSessionOwnership() + + XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed) + XCTAssertTrue(ownership.disconnect(connection(12))) + XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .rejected) + XCTAssertFalse(ownership.disconnect(connection(13))) + } + + func testStaleDisconnectCannotRemoveReusedFileDescriptorOwner() { + var ownership = AgentSessionOwnership() + let staleConnection = connection(12) + let otherConnection = connection(13) + let reusedDescriptorConnection = connection(14) + + XCTAssertEqual(ownership.registerConnection(staleConnection, authenticated: true), .claimed) + XCTAssertEqual(ownership.registerConnection(otherConnection, authenticated: true), .retained) + XCTAssertFalse(ownership.disconnect(staleConnection)) + XCTAssertEqual( + ownership.registerConnection(reusedDescriptorConnection, authenticated: true), + .retained + ) + + XCTAssertFalse(ownership.disconnect(staleConnection)) + XCTAssertFalse(ownership.disconnect(otherConnection)) + XCTAssertTrue(ownership.disconnect(reusedDescriptorConnection)) + } + + func testTokenlessSessionStillRequiresAuthorizedPeer() { + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: nil, + requestToken: nil, + authorizedPeer: false + )) + XCTAssertTrue(isAuthenticatedAgentSession( + expectedToken: nil, + requestToken: nil, + authorizedPeer: true + )) + } + + func testTokenSessionRequiresAuthorizedPeerAndMatchingToken() { + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "expected", + authorizedPeer: false + )) + XCTAssertFalse(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "wrong", + authorizedPeer: true + )) + XCTAssertTrue(isAuthenticatedAgentSession( + expectedToken: "expected", + requestToken: "expected", + authorizedPeer: true + )) + } +} + +private func connection(_ rawValue: UInt64) -> AgentSessionConnectionID { + AgentSessionConnectionID(rawValue: rawValue) +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift new file mode 100644 index 00000000000..1d335e98bb6 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/AuthenticatedConnectionHangupMonitorTests.swift @@ -0,0 +1,195 @@ +import Darwin +import Dispatch +@testable import OrcaComputerUseMacOSCore +import XCTest + +final class AuthenticatedConnectionHangupMonitorTests: XCTestCase { + func testRejectsInvalidDescriptorDuringSetup() { + XCTAssertThrowsError( + try AuthenticatedConnectionHangupMonitor( + fileDescriptor: -1, + onHangup: {} + ) + ) + } + + func testRegistrationFailureClosesEventQueueOnce() { + let closes = DescriptorCloseRecorder() + + XCTAssertThrowsError( + try AuthenticatedConnectionHangupMonitor( + fileDescriptor: 42, + registerEvents: { _, _, _ in .EBADF }, + closeDescriptor: { descriptor in + closes.recordAndClose(descriptor) + }, + onHangup: {} + ) + ) + XCTAssertEqual(closes.count, 1) + } + + func testReportsPeerCloseWhileRequestHandlingIsBlocked() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + let processingStarted = expectation(description: "processing started") + let releaseProcessing = DispatchSemaphore(value: 0) + let processingFinished = DispatchSemaphore(value: 0) + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + monitor.start() + + DispatchQueue.global().async { + processingStarted.fulfill() + releaseProcessing.wait() + processingFinished.signal() + } + wait(for: [processingStarted], timeout: 1) + close(descriptors.peer) + + wait(for: [hangup], timeout: 1) + XCTAssertEqual(processingFinished.wait(timeout: .now() + 0.05), .timedOut) + + releaseProcessing.signal() + XCTAssertEqual(processingFinished.wait(timeout: .now() + 1), .success) + monitor.cancel() + close(descriptors.local) + } + + func testReadableDataDoesNotLookLikeHangup() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + let callbacks = CallbackRecorder() + let events = CallbackRecorder() + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onEvent: { + events.record() + }, + onHangup: { + callbacks.record() + hangup.fulfill() + } + ) + monitor.start() + var byte: UInt8 = 7 + + XCTAssertEqual(write(descriptors.peer, &byte, 1), 1) + usleep(50_000) + XCTAssertEqual(callbacks.count, 0) + XCTAssertEqual(events.count, 0) + close(descriptors.peer) + wait(for: [hangup], timeout: 1) + XCTAssertEqual(events.count, 1) + + monitor.cancel() + close(descriptors.local) + } + + func testCancelPreventsLaterHangupCallback() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "peer hangup") + hangup.isInverted = true + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + monitor.start() + + monitor.cancel() + close(descriptors.peer) + + wait(for: [hangup], timeout: 0.1) + close(descriptors.local) + } + + func testCancelReleasesMonitorWhileSocketStaysOpen() throws { + let descriptors = try makeSocketPair() + var monitor: AuthenticatedConnectionHangupMonitor? = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: {} + ) + weak var retainedMonitor = monitor + + monitor?.cancel() + monitor = nil + + let deadline = Date().addingTimeInterval(1) + while retainedMonitor != nil, Date() < deadline { + usleep(10_000) + } + XCTAssertNil(retainedMonitor) + close(descriptors.peer) + close(descriptors.local) + } + + func testStartReportsHangupQueuedAfterSuccessfulSetup() throws { + let descriptors = try makeSocketPair() + let hangup = expectation(description: "queued peer hangup") + let monitor = try AuthenticatedConnectionHangupMonitor( + fileDescriptor: descriptors.local, + onHangup: { + hangup.fulfill() + } + ) + + close(descriptors.peer) + monitor.start() + + wait(for: [hangup], timeout: 1) + monitor.cancel() + close(descriptors.local) + } +} + +private final class CallbackRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedCount = 0 + + var count: Int { + lock.lock() + defer { lock.unlock() } + return recordedCount + } + + func record() { + lock.lock() + recordedCount += 1 + lock.unlock() + } +} + +private final class DescriptorCloseRecorder: @unchecked Sendable { + private let lock = NSLock() + private var closeCount = 0 + + var count: Int { + lock.lock() + defer { lock.unlock() } + return closeCount + } + + func recordAndClose(_ descriptor: Int32) { + lock.lock() + closeCount += 1 + let shouldClose = closeCount == 1 + lock.unlock() + if shouldClose { + close(descriptor) + } + } +} + +private func makeSocketPair() throws -> (local: Int32, peer: Int32) { + var descriptors: [Int32] = [0, 0] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { + throw POSIXError(.EIO) + } + return (descriptors[0], descriptors[1]) +} diff --git a/package.json b/package.json index 4c1e8474517..cacfbe341e1 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", + "bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", From cc078a50213d5110b04fbbc5f158c1f5a054834b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:33:05 -0700 Subject: [PATCH 037/123] perf(main): move hang watchdog into a worker thread (#11488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(main): add watchdog boundary memory benchmark Add a repeatable Electron 43 RSS harness that measures the production-built watchdog entry across the child-process and worker-thread boundaries. Record per-trial samples, the median, revision, runtime, and settling procedure for reproducible PR evidence. * perf(main): move hang watchdog into a worker thread Keep main-thread hang detection independent of the blocked Electron event loop without paying for a second ELECTRON_RUN_AS_NODE process. Preserve the marker and telemetry contract while moving timing configuration and heartbeats onto a bundled worker entry. * test(main): smoke packaged hang watchdog worker * fix(main): make packaged watchdog smoke able to fail The smoke reported failure only through process.exitCode, but its finally block quit Electron gracefully, and Electron takes its status from the browser exit code. Every failure mode — entry missing from app.asar, worker error, marker timeout, non-zero worker exit — exited 0 with the diagnostic discarded on stderr, so the required PR check could never go red. Propagate a real status via app.exit, assert the success line in stdout, and surface stderr. Verified against a packaged tree with the entry removed: exit 0 before, exit 1 after. Co-authored-by: Orca --------- Co-authored-by: Orca --- .github/workflows/pr.yml | 3 + build-plugins/plain-node-entry-guard.ts | 1 - config/electron-builder.config.cjs | 1 - .../scripts/electron-builder-config.test.mjs | 8 +- .../hang-watchdog-memory-benchmark.mjs | 443 ++++++++++++++++++ .../scripts/hang-watchdog-process-metrics.mjs | 108 +++++ .../hang-watchdog-process-metrics.test.mjs | 45 ++ ...ged-hang-watchdog-worker-contract.test.mjs | 35 ++ .../smoke-packaged-hang-watchdog-worker.mjs | 179 +++++++ electron.vite.config.ts | 5 +- package.json | 1 + .../hang-watchdog/hang-detection-marker.ts | 2 +- ...s => hang-watchdog-detection-loop.test.ts} | 6 +- ...oop.ts => hang-watchdog-detection-loop.ts} | 10 +- .../hang-watchdog/hang-watchdog-entry-path.ts | 17 - .../hang-watchdog-worker-path.test.ts | 26 + .../hang-watchdog-worker-path.ts | 14 + .../hang-watchdog-worker-protocol.ts | 12 + .../main-thread-hang-watchdog-entry.test.ts | 82 +++- .../main-thread-hang-watchdog-entry.ts | 97 ++-- .../main-thread-hang-watchdog.test.ts | 126 +++-- .../main-thread-hang-watchdog.ts | 101 ++-- 22 files changed, 1162 insertions(+), 160 deletions(-) create mode 100644 config/scripts/hang-watchdog-memory-benchmark.mjs create mode 100644 config/scripts/hang-watchdog-process-metrics.mjs create mode 100644 config/scripts/hang-watchdog-process-metrics.test.mjs create mode 100644 config/scripts/packaged-hang-watchdog-worker-contract.test.mjs create mode 100644 config/scripts/smoke-packaged-hang-watchdog-worker.mjs rename src/main/hang-watchdog/{hang-watchdog-child-loop.test.ts => hang-watchdog-detection-loop.test.ts} (96%) rename src/main/hang-watchdog/{hang-watchdog-child-loop.ts => hang-watchdog-detection-loop.ts} (86%) delete mode 100644 src/main/hang-watchdog/hang-watchdog-entry-path.ts create mode 100644 src/main/hang-watchdog/hang-watchdog-worker-path.test.ts create mode 100644 src/main/hang-watchdog/hang-watchdog-worker-path.ts create mode 100644 src/main/hang-watchdog/hang-watchdog-worker-protocol.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2f0e19980a6..261c8d18adb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -273,6 +273,9 @@ jobs: - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + - name: Smoke packaged hang watchdog worker + run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked + package_windows: name: package (windows) runs-on: windows-2022 diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 6638cca97c5..a7ad171a402 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk const PLAIN_NODE_ENTRY_NAMES = [ 'daemon-entry', 'parcel-watcher-process-entry', - 'main-thread-hang-watchdog-entry', 'computer-sidecar', 'agent-hooks/managed-agent-hook-controls', 'codex/codex-app-server-grant-entry' diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e7a80704c14..da0f690ad80 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -154,7 +154,6 @@ module.exports = { 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', - 'out/main/main-thread-hang-watchdog-entry.js', 'out/main/chunks/**', 'resources/**', 'node_modules/ws/**', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7d761c70af7..1dd0cf78ff3 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -171,11 +171,9 @@ describe('electron-builder config', () => { ) }) - // Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE - // fork resolves the entry from app.asar.unpacked — inside the asar it never runs. - it('unpacks the forked main-thread hang-watchdog entry', () => { - expect(electronBuilderConfig.asarUnpack).toEqual( - expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js']) + it('keeps the worker-thread hang watchdog inside app.asar', () => { + expect(electronBuilderConfig.asarUnpack).not.toContain( + 'out/main/main-thread-hang-watchdog-entry.js' ) }) diff --git a/config/scripts/hang-watchdog-memory-benchmark.mjs b/config/scripts/hang-watchdog-memory-benchmark.mjs new file mode 100644 index 00000000000..49a2dd63136 --- /dev/null +++ b/config/scripts/hang-watchdog-memory-benchmark.mjs @@ -0,0 +1,443 @@ +#!/usr/bin/env node +import { execFileSync, fork, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' +import { + childRssBytes, + median, + physicalFootprintBytes, + sampleMemory, + sampleProductionPerformance +} from './hang-watchdog-process-metrics.mjs' + +const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL' +const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY' +const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT=' +const DEFAULT_TRIALS = 7 +const SETTLE_MS = 2_000 +const SAMPLE_COUNT = 5 +const SAMPLE_INTERVAL_MS = 200 +const VERIFY_TIMEOUT_MS = 500 +const VERIFY_CHECK_INTERVAL_MS = 50 +const VERIFY_BLOCK_MS = 1_200 +const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000 +const PRODUCTION_TIMEOUT_MS = 45_000 +const PRODUCTION_CHECK_INTERVAL_MS = 5_000 +const PRODUCTION_SAMPLE_MS = 30_000 +const MAX_LAUNCH_ATTEMPTS = 3 +const MIB = 1024 * 1024 +const scriptPath = import.meta.filename +const repoRoot = path.resolve(import.meta.dirname, '..', '..') +const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function forceGc() { + if (typeof global.gc !== 'function') { + throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness') + } + global.gc() + global.gc() +} + +function blockMainThread(ms) { + const startedAt = Date.now() + while (Date.now() - startedAt < ms) { + // Intentional synchronous stall. + } + return { startedAt, endedAt: Date.now() } +} + +function readMarker(markerPath) { + try { + return JSON.parse(readFileSync(markerPath, 'utf8')) + } catch { + return null + } +} + +async function verifyBlockedMainDetection(markerPath, sendHeartbeat) { + const block = blockMainThread(VERIFY_BLOCK_MS) + const detected = readMarker(markerPath) + sendHeartbeat() + const deadline = Date.now() + VERIFY_TIMEOUT_MS + let resolved + do { + resolved = readMarker(markerPath) + if (resolved?.selfRecovered === true) { + break + } + await sleep(VERIFY_CHECK_INTERVAL_MS) + } while (Date.now() < deadline) + const verified = + detected?.detectedAt >= block.startedAt && + detected.detectedAt <= block.endedAt && + detected.selfRecovered === false && + resolved?.selfRecovered === true + if (!verified) { + throw new Error( + `Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}` + ) + } + return true +} + +function startChild(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid), + ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath, + ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs), + ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs) + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid, child.pid], + startupMs, + sendHeartbeat: () => child.send?.({ type: 'heartbeat' }), + shutdown: async () => { + if (child.exitCode !== null) { + return + } + const exitPromise = new Promise((resolve) => child.once('exit', resolve)) + child.send?.({ type: 'shutdown' }) + if (child.connected) { + child.disconnect() + } + await exitPromise + } + } +} + +function startWorker(markerPath, timeoutMs, checkIntervalMs) { + const startedAt = process.hrtime.bigint() + const worker = new Worker(entryPath, { + workerData: { + parentPid: process.pid, + markerPath, + timeoutMs, + checkIntervalMs + } + }) + const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + return { + pids: [process.pid], + startupMs, + sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }), + shutdown: async () => { + if (worker.threadId === -1) { + return + } + const exitPromise = new Promise((resolve) => worker.once('exit', resolve)) + worker.postMessage({ type: 'shutdown' }) + await exitPromise + } + } +} + +async function verifyBoundary(markerPath, startBoundary) { + const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS) + const heartbeat = setInterval(boundary.sendHeartbeat, 100) + try { + await sleep(SETTLE_MS) + return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat) + } finally { + clearInterval(heartbeat) + await boundary.shutdown() + } +} + +async function measureChild(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const childRss = await sampleMemory( + () => childRssBytes(child.pids[1]), + () => physicalFootprintBytes([child.pids[1]]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const total = await sampleMemory( + () => process.memoryUsage().rss + childRssBytes(child.pids[1]), + () => physicalFootprintBytes(child.pids), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(child, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + measurements = { + rssBytes: childRss.rssBytes, + summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes), + physicalFootprintDeltaBytes: Math.max( + 0, + total.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: child.startupMs, + ...performance + } + } finally { + await child.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startChild) + } +} + +async function measureWorker(markerPath) { + forceGc() + await sleep(SETTLE_MS) + forceGc() + const before = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS) + let measurements + try { + await sleep(SETTLE_MS) + forceGc() + const after = await sampleMemory( + () => process.memoryUsage().rss, + () => physicalFootprintBytes([process.pid]), + { sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep } + ) + const performance = await sampleProductionPerformance(worker, { + heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + sampleMs: PRODUCTION_SAMPLE_MS, + sleep + }) + const rssBytes = Math.max(0, after.rssBytes - before.rssBytes) + measurements = { + rssBytes, + summedProcessRssDeltaBytes: rssBytes, + physicalFootprintDeltaBytes: Math.max( + 0, + after.physicalFootprintBytes - before.physicalFootprintBytes + ), + startupMs: worker.startupMs, + ...performance + } + } finally { + await worker.shutdown() + } + rmSync(markerPath, { force: true }) + return { + ...measurements, + blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker) + } +} + +async function runInternal() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + const { app } = await import('electron') + const boundary = process.env[BOUNDARY_ENV] + const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-')) + app.setPath('userData', profileDir) + try { + await app.whenReady() + const markerPath = path.join(profileDir, 'main-thread-hang.json') + const result = + boundary === 'child' + ? await measureChild(markerPath) + : boundary === 'worker' + ? await measureWorker(markerPath) + : (() => { + throw new Error(`Unsupported boundary: ${boundary}`) + })() + process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`) + } finally { + app.quit() + rmSync(profileDir, { recursive: true, force: true }) + } +} + +function parseArgs(argv) { + const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = argv[index + 1] + if (arg === '--boundary' || arg === '--trials' || arg === '--output') { + if (!value) { + throw new Error(`Missing value for ${arg}`) + } + options[arg.slice(2)] = arg === '--trials' ? Number(value) : value + index += 1 + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!['child', 'worker'].includes(options.boundary)) { + throw new Error('--boundary must be child or worker') + } + if (!Number.isInteger(options.trials) || options.trials < 1) { + throw new Error('--trials must be a positive integer') + } + return options +} + +function electronPath() { + const requirePath = import.meta.resolve('electron') + const electronModulePath = fileURLToPath(requirePath) + return execFileSync( + process.execPath, + ['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`], + { + encoding: 'utf8' + } + ) +} + +function runTrial(executable, boundary) { + for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) { + const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary } + delete env.ELECTRON_RUN_AS_NODE + const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-')) + writeFileSync( + path.join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' }) + ) + writeFileSync( + path.join(launcherDir, 'main.cjs'), + `import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + let result + try { + result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], { + cwd: repoRoot, + env, + encoding: 'utf8', + timeout: 90_000 + }) + } finally { + rmSync(launcherDir, { recursive: true, force: true }) + } + if (result.status !== 0) { + throw new Error( + `Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` + + `${result.stderr || result.stdout}` + ) + } + const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX)) + if (line) { + return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt } + } + if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) { + throw new Error(`Electron trial did not report a result (status ${result.status})`) + } + } + throw new Error('Electron trial exhausted launcher attempts') +} + +function runBenchmark() { + if (process.platform !== 'darwin') { + throw new Error('The production watchdog is macOS-only; run this benchmark on macOS') + } + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`) + } + const options = parseArgs(process.argv.slice(2)) + const builtEntry = readFileSync(entryPath, 'utf8') + const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID') + const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort') + if ( + (options.boundary === 'child' && !hasChildContract) || + (options.boundary === 'worker' && !hasWorkerContract) + ) { + throw new Error( + `Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision` + ) + } + const executable = electronPath() + const results = Array.from({ length: options.trials }, () => + runTrial(executable, options.boundary) + ) + const rssBytes = results.map((result) => result.rssBytes) + const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes) + const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes) + const startupMs = results.map((result) => result.startupMs) + const cpuMs = results.map((result) => result.cpuMs) + const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms) + const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms) + const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs) + const report = { + benchmark: 'hang-watchdog-memory', + boundary: options.boundary, + revision: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8' + }).trim(), + electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + encoding: 'utf8' + }).trim(), + settleMs: SETTLE_MS, + samplesPerTrial: SAMPLE_COUNT, + productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS, + productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS, + productionSampleMs: PRODUCTION_SAMPLE_MS, + trials: options.trials, + rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))), + medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)), + summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)), + physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) => + Number((value / MIB).toFixed(2)) + ), + medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)), + startupMs: startupMs.map((value) => Number(value.toFixed(3))), + medianStartupMs: Number(median(startupMs).toFixed(3)), + cpuMs: cpuMs.map((value) => Number(value.toFixed(2))), + medianCpuMs: Number(median(cpuMs).toFixed(2)), + eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)), + eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)), + eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))), + medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)), + heartbeatCounts: results.map((result) => result.heartbeatCount), + launchAttempts: results.map((result) => result.launchAttempts), + blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified) + } + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options.output) { + writeFileSync(path.resolve(options.output), serialized) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + await runInternal() +} else { + runBenchmark() +} diff --git a/config/scripts/hang-watchdog-process-metrics.mjs b/config/scripts/hang-watchdog-process-metrics.mjs new file mode 100644 index 00000000000..7e5ee4de53f --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.mjs @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process' +import { monitorEventLoopDelay } from 'node:perf_hooks' + +export function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +export async function sampleMemory(readRss, readPhysicalFootprint, options) { + const rssSamples = [] + const physicalFootprintSamples = [] + for (let index = 0; index < options.sampleCount; index += 1) { + rssSamples.push(readRss()) + physicalFootprintSamples.push(readPhysicalFootprint()) + await options.sleep(options.sampleIntervalMs) + } + return { + rssBytes: median(rssSamples), + physicalFootprintBytes: median(physicalFootprintSamples) + } +} + +export function childRssBytes(pid) { + const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const rssKiB = Number(raw) + if (!Number.isFinite(rssKiB) || rssKiB <= 0) { + throw new Error(`Could not read watchdog child RSS for PID ${pid}`) + } + return rssKiB * 1024 +} + +export function parsePhysicalFootprintBytes(output, processCount) { + const match = + processCount > 1 + ? output.match(/^Summary Footprint:\s+(\d+) B$/m) + : output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m) + const bytes = Number(match?.[1]) + return Number.isFinite(bytes) && bytes > 0 ? bytes : null +} + +export function physicalFootprintBytes(pids) { + const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)]) + const output = execFileSync( + '/usr/bin/footprint', + [...pidArgs, '--format', 'bytes', '--noCategories'], + { encoding: 'utf8' } + ) + const bytes = parsePhysicalFootprintBytes(output, pids.length) + if (bytes === null) { + throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`) + } + return bytes +} + +export function parseProcessCpuTimeMs(raw) { + if (!raw.trim()) { + return null + } + const parts = raw.split(':').map(Number) + if (!parts.length || parts.some((part) => !Number.isFinite(part))) { + return null + } + const seconds = parts.reduce((total, part) => total * 60 + part, 0) + const milliseconds = seconds * 1_000 + return milliseconds >= 0 ? milliseconds : null +} + +function processCpuTimeMs(pid) { + const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], { + encoding: 'utf8' + }).trim() + const milliseconds = parseProcessCpuTimeMs(raw) + if (milliseconds === null) { + throw new Error(`Could not read CPU time for PID ${pid}`) + } + return milliseconds +} + +function combinedCpuTimeMs(pids) { + return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0) +} + +export async function sampleProductionPerformance(boundary, options) { + const loopDelay = monitorEventLoopDelay({ resolution: 10 }) + let heartbeatCount = 0 + const heartbeat = setInterval(() => { + heartbeatCount += 1 + boundary.sendHeartbeat() + }, options.heartbeatIntervalMs) + const cpuBefore = combinedCpuTimeMs(boundary.pids) + loopDelay.enable() + try { + await options.sleep(options.sampleMs) + } finally { + loopDelay.disable() + clearInterval(heartbeat) + } + return { + cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore), + heartbeatCount, + eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6, + eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6, + eventLoopDelayMaxMs: loopDelay.max / 1e6 + } +} diff --git a/config/scripts/hang-watchdog-process-metrics.test.mjs b/config/scripts/hang-watchdog-process-metrics.test.mjs new file mode 100644 index 00000000000..2d54e691d00 --- /dev/null +++ b/config/scripts/hang-watchdog-process-metrics.test.mjs @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + parsePhysicalFootprintBytes, + parseProcessCpuTimeMs +} from './hang-watchdog-process-metrics.mjs' + +describe('hang watchdog process metrics', () => { + it('uses the de-duplicated summary for multiple processes', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page) + phys_footprint: 2100000 B +Summary Footprint: 6259264 B +` + expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264) + }) + + it('uses the process footprint rather than auxiliary accounting for one process', () => { + const output = ` +Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page) + phys_footprint: 5100000 B +` + expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000) + }) + + it('rejects missing or zero footprint summaries', () => { + expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull() + expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull() + }) + + it.each([ + ['0:00.04', 40], + ['1:02.50', 62_500], + ['2:01:02.50', 7_262_500] + ])('parses ps CPU time %s', (value, expected) => { + expect(parseProcessCpuTimeMs(value)).toBe(expected) + }) + + it('rejects invalid CPU times', () => { + expect(parseProcessCpuTimeMs('')).toBeNull() + expect(parseProcessCpuTimeMs('not-a-time')).toBeNull() + expect(parseProcessCpuTimeMs('-1:00')).toBeNull() + }) +}) diff --git a/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs new file mode 100644 index 00000000000..1ae558c7691 --- /dev/null +++ b/config/scripts/packaged-hang-watchdog-worker-contract.test.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +describe('packaged hang watchdog worker contract', () => { + it('boots the worker from app.asar in PR checks', () => { + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + const smokeSource = readFileSync( + 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', + 'utf8' + ) + const smokeStep = workflow.jobs.package.steps.find( + (step) => step.name === 'Smoke packaged hang watchdog worker' + ) + + expect(smokeStep.run).toBe( + 'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked' + ) + expect(smokeSource).toContain( + "process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]" + ) + }) + + // Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion. + it('fails the smoke when the packaged worker never reports success', () => { + const smokeSource = readFileSync( + 'config/scripts/smoke-packaged-hang-watchdog-worker.mjs', + 'utf8' + ) + + expect(smokeSource).toContain('app.exit(1)') + expect(smokeSource).not.toContain('app.quit()') + expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))') + }) +}) diff --git a/config/scripts/smoke-packaged-hang-watchdog-worker.mjs b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs new file mode 100644 index 00000000000..ae9b340785a --- /dev/null +++ b/config/scripts/smoke-packaged-hang-watchdog-worker.mjs @@ -0,0 +1,179 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Worker } from 'node:worker_threads' + +const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL' +const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR' +const TIMEOUT_MS = 100 +const CHECK_INTERVAL_MS = 20 +const POLL_TIMEOUT_MS = 5_000 +const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall' + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +function readAppDirArg(argv) { + const explicit = argv.find((arg) => arg.startsWith('--app-dir=')) + if (explicit) { + return explicit.slice('--app-dir='.length) + } + if (process.platform === 'darwin') { + return 'dist/mac-arm64/Orca.app' + } + if (process.platform === 'win32') { + return 'dist/win-unpacked' + } + return 'dist/linux-unpacked' +} + +function getResourcesDir(appDir) { + return process.platform === 'darwin' || appDir.endsWith('.app') + ? join(appDir, 'Contents', 'Resources') + : join(appDir, 'resources') +} + +function readMarker(markerPath) { + try { + return JSON.parse(readFileSync(markerPath, 'utf8')) + } catch { + return null + } +} + +async function waitForMarker(markerPath, predicate, workerError) { + const deadline = Date.now() + POLL_TIMEOUT_MS + while (Date.now() < deadline) { + if (workerError.current) { + throw workerError.current + } + const marker = readMarker(markerPath) + if (predicate(marker)) { + return marker + } + await sleep(CHECK_INTERVAL_MS) + } + throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`) +} + +async function waitForExit(worker, workerError) { + const exitCode = await Promise.race([ + new Promise((resolveExit) => worker.once('exit', resolveExit)), + sleep(POLL_TIMEOUT_MS).then(() => { + throw new Error('Timed out waiting for packaged watchdog worker to exit') + }) + ]) + if (workerError.current) { + throw workerError.current + } + if (exitCode !== 0) { + throw new Error(`Packaged watchdog worker exited with code ${exitCode}`) + } +} + +async function runInternal() { + const appAsar = process.env[ASAR_ENV] + if (!appAsar) { + throw new Error(`Missing ${ASAR_ENV}`) + } + const { app } = await import('electron') + const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-')) + const markerPath = join(tempRoot, 'main-thread-hang.json') + const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + let worker + try { + await app.whenReady() + if (!existsSync(entryPath)) { + throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`) + } + const workerError = { current: null } + worker = new Worker(entryPath, { + workerData: { + parentPid: process.pid, + markerPath, + timeoutMs: TIMEOUT_MS, + checkIntervalMs: CHECK_INTERVAL_MS + } + }) + worker.once('error', (error) => { + workerError.current = error + }) + await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError) + worker.postMessage({ type: 'heartbeat' }) + await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError) + worker.postMessage({ type: 'shutdown' }) + await waitForExit(worker, workerError) + worker = undefined + console.log(SUCCESS_LINE) + } finally { + await worker?.terminate() + rmSync(tempRoot, { recursive: true, force: true }) + } +} + +function runSmoke() { + const appDir = resolve(readAppDirArg(process.argv.slice(2))) + const appAsar = join(getResourcesDir(appDir), 'app.asar') + if (!existsSync(appAsar)) { + throw new Error(`Packaged app archive is missing: ${appAsar}`) + } + const require = createRequire(import.meta.url) + const executable = require('electron') + const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-')) + const launcherPath = join(launcherDir, 'main.cjs') + writeFileSync( + join(launcherDir, 'package.json'), + JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' }) + ) + writeFileSync( + launcherPath, + `import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => { + console.error(error) + process.exitCode = 1 +})\n` + ) + const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' } + delete env.ELECTRON_RUN_AS_NODE + try { + const electronArgs = + process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir] + const result = spawnSync(executable, electronArgs, { + env, + encoding: 'utf8', + timeout: 15_000 + }) + if (result.status !== 0) { + throw new Error( + `Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` + + `${result.stderr || result.stdout}` + ) + } + // Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran. + if (!result.stdout.includes(SUCCESS_LINE)) { + throw new Error( + `Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}` + ) + } + process.stdout.write(result.stdout) + } finally { + rmSync(launcherDir, { recursive: true, force: true }) + } +} + +if (process.env[INTERNAL_ENV] === '1') { + // Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure. + const { app } = await import('electron') + try { + await runInternal() + app.exit(0) + } catch (error) { + console.error(error) + app.exit(1) + } +} else { + runSmoke() +} diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4a4cd6c8eba..957e3f60b38 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -243,9 +243,8 @@ export const electronViteConfig: UserConfig = { // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), - // Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked - // main thread (macOS 26 AppKit scene-update deadlock) and can record - // the stall for the next launch to report. + // Why: a worker thread survives the macOS 26 AppKit main-thread deadlock + // without paying for another Electron process. 'main-thread-hang-watchdog-entry': resolve( 'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts' ), diff --git a/package.json b/package.json index cacfbe341e1..3c37bc18efb 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs", diff --git a/src/main/hang-watchdog/hang-detection-marker.ts b/src/main/hang-watchdog/hang-detection-marker.ts index a6e6fbddd08..a32d872b8ec 100644 --- a/src/main/hang-watchdog/hang-detection-marker.ts +++ b/src/main/hang-watchdog/hang-detection-marker.ts @@ -1,7 +1,7 @@ import { readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -// Why: written by the plain-Node watchdog child when main-thread heartbeats stop, and rewritten if +// Why: written by the watchdog worker when main-thread heartbeats stop, and rewritten if // they resume; consumed on the next launch to report how long the stall lasted and whether it ever // cleared. `selfRecovered` separates a real deadlock from a long-but-survivable stall — the two are // indistinguishable at detection time, and only the latter would have been a destructive kill. diff --git a/src/main/hang-watchdog/hang-watchdog-child-loop.test.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts similarity index 96% rename from src/main/hang-watchdog/hang-watchdog-child-loop.test.ts rename to src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts index 8d390c04e8e..8d1c3ebfcd1 100644 --- a/src/main/hang-watchdog/hang-watchdog-child-loop.test.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop' +import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop' const TIMEOUT_MS = 45_000 const CHECK_INTERVAL_MS = 5_000 @@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) { let now = startAt const onHangDetected = vi.fn() const onHangResolved = vi.fn() - const loop = createHangWatchdogChildLoop({ + const loop = createHangWatchdogDetectionLoop({ timeoutMs: TIMEOUT_MS, checkIntervalMs: CHECK_INTERVAL_MS, now: () => now, @@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) { return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) } } -describe('createHangWatchdogChildLoop', () => { +describe('createHangWatchdogDetectionLoop', () => { it('does not fire while heartbeats keep arriving', () => { const { loop, onHangDetected, advance } = loopWithClock() for (let i = 0; i < 100; i++) { diff --git a/src/main/hang-watchdog/hang-watchdog-child-loop.ts b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts similarity index 86% rename from src/main/hang-watchdog/hang-watchdog-child-loop.ts rename to src/main/hang-watchdog/hang-watchdog-detection-loop.ts index 3a8ebb34fdf..9d45cba7797 100644 --- a/src/main/hang-watchdog/hang-watchdog-child-loop.ts +++ b/src/main/hang-watchdog/hang-watchdog-detection-loop.ts @@ -1,4 +1,4 @@ -export type HangWatchdogChildLoopConfig = { +export type HangWatchdogDetectionLoopConfig = { timeoutMs: number checkIntervalMs: number now: () => number @@ -7,14 +7,14 @@ export type HangWatchdogChildLoopConfig = { onHangResolved: (unresponsiveMs: number) => void } -export type HangWatchdogChildLoop = { +export type HangWatchdogDetectionLoop = { recordHeartbeat: () => void tick: () => void } -export function createHangWatchdogChildLoop( - config: HangWatchdogChildLoopConfig -): HangWatchdogChildLoop { +export function createHangWatchdogDetectionLoop( + config: HangWatchdogDetectionLoopConfig +): HangWatchdogDetectionLoop { let lastHeartbeatAt = config.now() let lastTickAt = config.now() let detected = false diff --git a/src/main/hang-watchdog/hang-watchdog-entry-path.ts b/src/main/hang-watchdog/hang-watchdog-entry-path.ts deleted file mode 100644 index 74f8b1d0089..00000000000 --- a/src/main/hang-watchdog/hang-watchdog-entry-path.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' - -export function resolveHangWatchdogEntryPath( - appPath: string, - isPackaged: boolean, - pathExists: (candidate: string) => boolean = existsSync -): string { - // Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked. - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath - const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js') - // Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds. - if (!isPackaged && pathExists(adjacentBuildEntry)) { - return adjacentBuildEntry - } - return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') -} diff --git a/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts b/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts new file mode 100644 index 00000000000..3ade5b1cbf3 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-path.test.ts @@ -0,0 +1,26 @@ +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path' + +describe('resolveHangWatchdogWorkerPath', () => { + it('resolves packaged workers inside app.asar', () => { + const appPath = join('/apps', 'orca', 'app.asar') + expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe( + join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + }) + + it('uses an adjacent entry when the dev app path is out/main', () => { + const appPath = join('/repo', 'out', 'main') + const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js') + const pathExists = vi.fn((candidate: string) => candidate === adjacent) + expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent) + }) + + it('resolves through out/main from a dev project root', () => { + const appPath = join('/repo', 'orca') + expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe( + join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + }) +}) diff --git a/src/main/hang-watchdog/hang-watchdog-worker-path.ts b/src/main/hang-watchdog/hang-watchdog-worker-path.ts new file mode 100644 index 00000000000..7efc300cb45 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-path.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +export function resolveHangWatchdogWorkerPath( + appPath: string, + isPackaged: boolean, + pathExists: (candidate: string) => boolean = existsSync +): string { + const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js') + if (!isPackaged && pathExists(adjacentBuildEntry)) { + return adjacentBuildEntry + } + return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js') +} diff --git a/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts b/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts new file mode 100644 index 00000000000..14e6d007796 --- /dev/null +++ b/src/main/hang-watchdog/hang-watchdog-worker-protocol.ts @@ -0,0 +1,12 @@ +export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000 +export const HANG_WATCHDOG_TIMEOUT_MS = 45_000 +export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000 + +export type HangWatchdogWorkerData = { + parentPid: number + markerPath: string + timeoutMs: number + checkIntervalMs: number +} + +export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' } diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts index 0f36e8acad9..a1ececdadb4 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.test.ts @@ -12,7 +12,11 @@ vi.mock('node:child_process', () => ({ spawn: spawnMock })) -import { recordHangObservation } from './main-thread-hang-watchdog-entry' +import { + isHangWatchdogWorkerData, + recordHangObservation, + runWatchdog +} from './main-thread-hang-watchdog-entry' describe('recordHangObservation', () => { let dir: string @@ -67,10 +71,8 @@ describe('recordHangObservation', () => { }) }) - // Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch, - // or exit — a false positive would SIGKILL a live main thread mid-write. If a future change - // reintroduces recovery, this test must fail loudly rather than ship silently. it('never spawns, signals, or exits', () => { + // Why: a false positive must never kill a live main thread while writes may be in flight. recordHangObservation({ parentPid: 4242, markerPath: join(dir, 'marker.json'), @@ -104,3 +106,75 @@ describe('recordHangObservation', () => { ).not.toThrow() }) }) + +describe('watchdog worker entry', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('accepts only complete positive worker data', () => { + expect( + isHangWatchdogWorkerData({ + parentPid: 42, + markerPath: '/tmp/marker', + timeoutMs: 100, + checkIntervalMs: 25 + }) + ).toBe(true) + for (const value of [ + null, + {}, + { parentPid: 0, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: 7, timeoutMs: 100, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 0, checkIntervalMs: 25 }, + { parentPid: 42, markerPath: '/tmp/marker', timeoutMs: 100, checkIntervalMs: Infinity } + ]) { + expect(isHangWatchdogWorkerData(value)).toBe(false) + } + }) + + it('routes heartbeats and shuts down its timer and port', () => { + const markerPath = join(tmpdir(), `hang-watchdog-entry-${process.pid}.json`) + let onMessage: ((message: { type: 'heartbeat' | 'shutdown' }) => void) | undefined + const port = { + on: vi.fn( + (_event: 'message', listener: (message: { type: 'heartbeat' | 'shutdown' }) => void) => { + onMessage = listener + } + ), + close: vi.fn() + } + try { + runWatchdog( + { + parentPid: process.pid, + markerPath, + timeoutMs: 100, + checkIntervalMs: 25 + }, + port + ) + vi.advanceTimersByTime(75) + onMessage?.({ type: 'heartbeat' }) + vi.advanceTimersByTime(75) + expect(consumeHangDetectionMarker(markerPath)).toBeNull() + vi.advanceTimersByTime(50) + expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: false }) + + onMessage?.({ type: 'heartbeat' }) + expect(consumeHangDetectionMarker(markerPath)).toMatchObject({ selfRecovered: true }) + onMessage?.({ type: 'shutdown' }) + expect(port.close).toHaveBeenCalledOnce() + vi.advanceTimersByTime(1_000) + expect(port.close).toHaveBeenCalledOnce() + expect(consumeHangDetectionMarker(markerPath)).toBeNull() + } finally { + rmSync(markerPath, { force: true }) + } + }) +}) diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts index 8d45c725a72..2c60b17e9ff 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts @@ -1,20 +1,17 @@ -// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main -// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so -// the next launch can report the stall, and rewrites it if the heartbeats come back. -// -// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce -// window, so killing it recovers nothing the user could not recover by force-quitting. A false -// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on -// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this -// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong. -// -// Must never import electron. -import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop' +import { isMainThread, parentPort, workerData } from 'node:worker_threads' +import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop' import { writeHangDetectionMarker } from './hang-detection-marker' +import type { + HangWatchdogWorkerData, + MainToHangWatchdogWorkerMessage +} from './hang-watchdog-worker-protocol' -const DEFAULT_TIMEOUT_MS = 45_000 -const DEFAULT_CHECK_INTERVAL_MS = 5_000 +type HangWatchdogPort = { + on: (event: 'message', listener: (message: MainToHangWatchdogWorkerMessage) => void) => unknown + close: () => void +} +// Observation only: a false positive must never kill a live main thread mid-write. export function recordHangObservation(options: { parentPid: number markerPath: string @@ -36,37 +33,65 @@ export function recordHangObservation(options: { } } -function runWatchdog(parentPid: number): void { - const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? '' - const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS - const checkIntervalMs = - Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS - - const loop = createHangWatchdogChildLoop({ - timeoutMs, - checkIntervalMs, +export function runWatchdog( + config: HangWatchdogWorkerData, + port: HangWatchdogPort | null = parentPort +): void { + if (!port) { + return + } + const loop = createHangWatchdogDetectionLoop({ + timeoutMs: config.timeoutMs, + checkIntervalMs: config.checkIntervalMs, now: () => Date.now(), onHangDetected: (unresponsiveMs) => - recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }), + recordHangObservation({ + parentPid: config.parentPid, + markerPath: config.markerPath, + unresponsiveMs, + selfRecovered: false + }), // Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile. onHangResolved: (unresponsiveMs) => - recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true }) + recordHangObservation({ + parentPid: config.parentPid, + markerPath: config.markerPath, + unresponsiveMs, + selfRecovered: true + }) }) - process.on('message', (message) => { - const type = (message as { type?: string } | null)?.type - if (type === 'heartbeat') { + let checkTimer: ReturnType | null = setInterval( + () => loop.tick(), + config.checkIntervalMs + ) + port.on('message', (message: MainToHangWatchdogWorkerMessage) => { + if (message.type === 'heartbeat') { loop.recordHeartbeat() - } else if (type === 'shutdown') { - process.exit(0) + } else if (message.type === 'shutdown') { + if (checkTimer) { + clearInterval(checkTimer) + checkTimer = null + } + port.close() } }) - // Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire. - process.on('disconnect', () => process.exit(0)) - setInterval(() => loop.tick(), checkIntervalMs) } -const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID) -if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) { - runWatchdog(configuredParentPid) +export function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData { + const data = value as Partial | null + return ( + !!data && + Number.isInteger(data.parentPid) && + (data.parentPid ?? 0) > 0 && + typeof data.markerPath === 'string' && + Number.isFinite(data.timeoutMs) && + (data.timeoutMs ?? 0) > 0 && + Number.isFinite(data.checkIntervalMs) && + (data.checkIntervalMs ?? 0) > 0 + ) +} + +if (!isMainThread && isHangWatchdogWorkerData(workerData)) { + runWatchdog(workerData) } diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts b/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts index b5c1063a915..eaaea54225b 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' -const { forkMock, appMock } = vi.hoisted(() => ({ - forkMock: vi.fn(), +const { workerState, appMock } = vi.hoisted(() => ({ + workerState: { + calls: [] as unknown[][], + instance: null as object | null, + error: null as Error | null + }, appMock: { isPackaged: true, getAppPath: vi.fn(() => '/apps/orca/app.asar'), @@ -9,8 +14,16 @@ const { forkMock, appMock } = vi.hoisted(() => ({ } })) -vi.mock('node:child_process', () => ({ - fork: forkMock +vi.mock('node:worker_threads', () => ({ + Worker: class WorkerMock { + constructor(...args: unknown[]) { + workerState.calls.push(args) + if (workerState.error) { + throw workerState.error + } + return workerState.instance as WorkerMock + } + } })) vi.mock('electron', () => ({ @@ -29,28 +42,33 @@ function withPlatform(platform: NodeJS.Platform, run: () => T): T { } } -function fakeChild() { +function fakeWorker() { return { - connected: true, - stderr: { on: vi.fn() }, + postMessage: vi.fn(), + unref: vi.fn(), on: vi.fn(), - send: vi.fn(), - disconnect: vi.fn(), - kill: vi.fn() + once: vi.fn() } } describe('installMainThreadHangWatchdog', () => { beforeEach(() => { vi.useFakeTimers() - forkMock.mockReset() + workerState.calls = [] + workerState.instance = null + workerState.error = null appMock.on.mockReset() appMock.isPackaged = true delete process.env.ORCA_HANG_WATCHDOG_FORCE + delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS + delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS }) afterEach(() => { vi.useRealTimers() + delete process.env.ORCA_HANG_WATCHDOG_FORCE + delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS + delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS }) it('is a no-op off macOS', () => { @@ -60,7 +78,7 @@ describe('installMainThreadHangWatchdog', () => { expect( withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() - expect(forkMock).not.toHaveBeenCalled() + expect(workerState.calls).toHaveLength(0) }) it('is a no-op in unpackaged builds unless forced', () => { @@ -69,62 +87,98 @@ describe('installMainThreadHangWatchdog', () => { withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() process.env.ORCA_HANG_WATCHDOG_FORCE = '1' - const child = fakeChild() - forkMock.mockReturnValue(child) + const worker = fakeWorker() + workerState.instance = worker expect( withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).not.toBeNull() delete process.env.ORCA_HANG_WATCHDOG_FORCE }) - it('forks the watchdog as plain Node with pid, bundle, and marker config', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + it('starts a worker with pid, marker, and timing config', () => { + const worker = fakeWorker() + workerState.instance = worker const handle = withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }) ) expect(handle).not.toBeNull() - const [, , options] = forkMock.mock.calls[0] - expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') - expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid)) - expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud') + const [workerPath, rawOptions] = workerState.calls[0] + const options = rawOptions as { + name: string + workerData: { + parentPid: number + markerPath: string + timeoutMs: number + checkIntervalMs: number + } + } + expect(workerPath).toBe( + join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js') + ) + expect(options.name).toBe('orca-main-thread-hang-watchdog') + expect(options.workerData).toMatchObject({ + parentPid: process.pid, + markerPath: join('/ud', 'main-thread-hang.json'), + timeoutMs: 45_000, + checkIntervalMs: 5_000 + }) + expect(worker.unref).toHaveBeenCalled() }) - it('sends heartbeats on an interval and shutdown+disconnect on stop', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + it('sends heartbeats on an interval and shutdown on stop', () => { + const worker = fakeWorker() + workerState.instance = worker const handle = withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }) ) vi.advanceTimersByTime(6_000) - const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat') + const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat') expect(heartbeats.length).toBe(3) handle?.stop() - expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true) - expect(child.disconnect).toHaveBeenCalled() + expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true) - // Why: quit fires will-quit twice; a second stop must not resend or throw. handle?.stop() - const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown') + const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown') expect(shutdowns.length).toBe(1) vi.advanceTimersByTime(10_000) - const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat') + const heartbeatsAfterStop = worker.postMessage.mock.calls.filter( + ([m]) => m.type === 'heartbeat' + ) expect(heartbeatsAfterStop.length).toBe(3) }) + it('passes test timing overrides to the worker', () => { + process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900' + process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100' + workerState.instance = fakeWorker() + withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) + const options = workerState.calls[0][1] as { + workerData: { timeoutMs: number; checkIntervalMs: number } + } + expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 }) + }) + it('registers stop on will-quit', () => { - const child = fakeChild() - forkMock.mockReturnValue(child) + workerState.instance = fakeWorker() withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function)) }) - it('returns null and stays inert when the fork itself fails', () => { - forkMock.mockImplementation(() => { - throw new Error('spawn failure') - }) + it('stops heartbeat work when the worker exits', () => { + const worker = fakeWorker() + workerState.instance = worker + withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) + const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1] + expect(exitListener).toEqual(expect.any(Function)) + exitListener() + vi.advanceTimersByTime(6_000) + expect(worker.postMessage).not.toHaveBeenCalled() + }) + + it('returns null and stays inert when worker construction fails', () => { + workerState.error = new Error('worker failure') expect( withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' })) ).toBeNull() diff --git a/src/main/hang-watchdog/main-thread-hang-watchdog.ts b/src/main/hang-watchdog/main-thread-hang-watchdog.ts index f859f8b5745..46454429fc7 100644 --- a/src/main/hang-watchdog/main-thread-hang-watchdog.ts +++ b/src/main/hang-watchdog/main-thread-hang-watchdog.ts @@ -1,19 +1,25 @@ -import { fork, type ChildProcess } from 'node:child_process' +import { Worker } from 'node:worker_threads' import { app } from 'electron' -import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path' import { hangDetectionMarkerPath } from './hang-detection-marker' - -const HEARTBEAT_INTERVAL_MS = 2_000 +import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path' +import { + HANG_WATCHDOG_CHECK_INTERVAL_MS, + HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS, + HANG_WATCHDOG_TIMEOUT_MS, + type HangWatchdogWorkerData, + type MainToHangWatchdogWorkerMessage +} from './hang-watchdog-worker-protocol' export type MainThreadHangWatchdogHandle = { stop: () => void - child: ChildProcess + worker: Worker +} + +function positiveTiming(value: string | undefined, fallback: number): number { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback } -// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside -// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling -// process watches heartbeats and records the stall so the next launch can report it. It observes -// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent. export function installMainThreadHangWatchdog(options: { userDataPath: string }): MainThreadHangWatchdogHandle | null { @@ -24,58 +30,57 @@ export function installMainThreadHangWatchdog(options: { if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') { return null } - const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged) - let child: ChildProcess + const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged) + const workerData: HangWatchdogWorkerData = { + parentPid: process.pid, + markerPath: hangDetectionMarkerPath(options.userDataPath), + timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS), + checkIntervalMs: positiveTiming( + process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS, + HANG_WATCHDOG_CHECK_INTERVAL_MS + ) + } + let worker: Worker try { - child = fork(entryPath, [], { - stdio: ['ignore', 'ignore', 'pipe', 'ipc'], - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid), - ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath) - } + // Why: the worker survives an AppKit main-thread deadlock without another Electron process. + worker = new Worker(workerPath, { + name: 'orca-main-thread-hang-watchdog', + workerData }) } catch (error) { - console.error('[hang-watchdog] failed to fork watchdog process:', error) + console.error('[hang-watchdog] failed to start watchdog worker:', error) return null } - child.stderr?.on('data', (chunk: Buffer) => { - console.error('[hang-watchdog]', String(chunk).trimEnd()) + worker.on('error', (error) => { + console.error('[hang-watchdog] watchdog worker failed:', error) }) - // Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app. - child.on('error', () => {}) - const heartbeatTimer = setInterval(() => { - if (child.connected) { - try { - child.send({ type: 'heartbeat' }, () => {}) - } catch { - // Channel raced closed between the check and the send. - } - } - }, HEARTBEAT_INTERVAL_MS) let stopped = false + const postMessage = (message: MainToHangWatchdogWorkerMessage): void => { + if (stopped && message.type === 'heartbeat') { + return + } + try { + worker.postMessage(message) + } catch { + // The worker already exited. + } + } + const heartbeatTimer = setInterval(() => { + postMessage({ type: 'heartbeat' }) + }, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS) const stop = (): void => { if (stopped) { return } stopped = true clearInterval(heartbeatTimer) - if (child.connected) { - try { - child.send({ type: 'shutdown' }, () => {}) - } catch { - // Already disconnecting. - } - } - // Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang. - try { - child.disconnect() - } catch { - // Channel already closed. - } + postMessage({ type: 'shutdown' }) } - // Why: will-quit fires twice during quit; stop is idempotent. + worker.once('exit', () => { + stopped = true + clearInterval(heartbeatTimer) + }) + worker.unref() app.on('will-quit', stop) - return { stop, child } + return { stop, worker } } From b5abab8b095092a72d5b1c81607ecee95989929d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:39:33 -0700 Subject: [PATCH 038/123] Virtualize workspace board lanes for faster open (#11269) * Virtualize workspace board lanes and defer card render for instant open * fix(review): harden kanban virtualization interaction edges Cancel deferred card mount on close, re-apply marquee preview after remounts, query drag styles from live DOM, drop re-exports/casts, and cover edge cases. * Remove virtualizer from effect deps to avoid unnecessary re-runs * Fix stale closure in kanban area-selection and card-drag handlers - Refresh area selection measurements on pointer up to avoid stale cache - Read worktree IDs ref directly in drag handler to close stale closure * Remove itemIds ref for correct virtualizer layout memoization The card list kept itemIds in a mutable ref that was manually synced on every render, so the layout registration effect never re-ran when the lane contents changed, leaving stale measurements. Read itemIds directly and declare it as an effect dependency so layout registration stays in sync with the items. --- .../sidebar/WorkspaceKanbanCard.tsx | 5 + .../sidebar/WorkspaceKanbanDrawer.tsx | 41 ++- .../WorkspaceKanbanLaneCardList.test.tsx | 119 +++++++++ .../sidebar/WorkspaceKanbanLaneCardList.tsx | 119 +++++++++ .../sidebar/WorkspaceKanbanLaneGrid.tsx | 8 +- .../WorkspaceKanbanStatusLane.test.tsx | 11 +- .../sidebar/WorkspaceKanbanStatusLane.tsx | 49 ++-- .../use-workspace-kanban-area-selection.ts | 27 +- .../use-workspace-kanban-card-pointer-drag.ts | 33 ++- ...kspace-kanban-area-selection-card-rects.ts | 99 ++++++++ .../workspace-kanban-area-selection-dom.ts | 82 ++---- .../workspace-kanban-area-selection.test.ts | 64 ++++- .../workspace-kanban-card-drag-preview-dom.ts | 25 +- ...space-kanban-card-pointer-drag-dom.test.ts | 42 ++++ .../workspace-kanban-card-pointer-drag-dom.ts | 88 +++++-- .../workspace-kanban-sidebar-drop.test.ts | 71 ++++++ .../sidebar/workspace-kanban-sidebar-drop.ts | 20 ++ ...rkspace-kanban-virtual-lane-layout.test.ts | 173 +++++++++++++ .../workspace-kanban-virtual-lane-layout.ts | 159 ++++++++++++ ...orkspace-board-lane-virtualization.spec.ts | 238 ++++++++++++++++++ 20 files changed, 1323 insertions(+), 150 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts create mode 100644 tests/e2e/workspace-board-lane-virtualization.spec.ts diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index 601bef497d6..cd7b94dd725 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -7,6 +7,9 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanCardProps = { worktree: Worktree + // Why: the lane virtualizes, so DOM order no longer implies list position. + // Drop-index math reads this instead of counting rendered siblings. + laneIndex: number repo: Repo | undefined isActive: boolean isSelected: boolean @@ -23,6 +26,7 @@ type WorkspaceKanbanCardProps = { function WorkspaceKanbanCard({ worktree, + laneIndex, repo, isActive, isSelected, @@ -40,6 +44,7 @@ function WorkspaceKanbanCard({
    (null) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const [renderCards, setRenderCards] = useState(false) const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree() const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, @@ -191,6 +201,12 @@ export default function WorkspaceKanbanDrawer({ })), [worktreesByStatus, workspaceStatuses] ) + useLayoutEffect(() => { + if (!open) { + return + } + return registerWorkspaceKanbanSidebarDropGroups(boardDragGroups) + }, [boardDragGroups, open]) const laneFullWorktreeIds = useMemo( () => new Map(boardDragGroups.map((group) => [group.key, group.worktreeIds])), [boardDragGroups] @@ -692,6 +708,28 @@ export default function WorkspaceKanbanDrawer({ } ) + // Why: mounting every lane's cards in the same commit that opens the sheet + // blocks the slide-in for the whole card render. Paint the board chrome + // first, then fill the lanes in a non-blocking transition. + useEffect(() => { + if (!open) { + setRenderCards(false) + return + } + let cancelled = false + const frameId = window.requestAnimationFrame(() => { + startTransition(() => { + if (!cancelled) { + setRenderCards(true) + } + }) + }) + return () => { + cancelled = true + window.cancelAnimationFrame(frameId) + } + }, [open]) + useWorkspaceKanbanShiftWheelScroll(boardRef, laneScrollerRef, open, isPointerDragActiveRef) useWorkspaceKanbanOutsideDismiss({ open, boardRef, preserveOpenForMenu, onOpenChange }) useContextualTour('workspace-board', open && !dragPreview, 'workspace_board_visible') @@ -871,6 +909,7 @@ export default function WorkspaceKanbanDrawer({ isResizingColumn={isResizingColumn} dragOverStatus={dragOverStatus} canCreateWorktree={canCreateWorktree} + renderCards={renderCards} selectedWorktreeIds={selectedWorktreeIds} selectedWorktrees={renderedSelectedWorktrees} onDragOver={handleDragOver} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx new file mode 100644 index 00000000000..d5b9b5c4176 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom +/** + * Why a render test: the board's open cost was mounting every card in every + * lane at once. These assert the lane renders only the virtual window, and that + * each rendered card still advertises its true lane index — drop-index math + * reads that attribute now that DOM order no longer implies list order. + * + * The virtualizer itself is mocked (as the sidebar list tests do): it needs real + * layout, which the test DOM has none of, and the contract under test is ours. + */ +import { cleanup, render } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../../../shared/types' + +const WINDOW_START = 20 +const WINDOW_END = 25 +const TOTAL_SIZE = 21_992 + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => { + const start = count === 0 ? 0 : Math.min(WINDOW_START, count - 1) + const end = count === 0 ? -1 : Math.min(WINDOW_END, count - 1) + return { + getTotalSize: () => (count === 0 ? 0 : count * 44 - 8), + measurementsCache: Array.from({ length: count }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })), + getVirtualItems: () => + Array.from({ length: Math.max(0, end - start + 1) }, (_, offset) => { + const index = start + offset + return { index, key: `w${index}`, start: index * 44 } + }), + measureElement: () => {} + } + } +})) + +vi.mock('./WorkspaceKanbanCard', () => ({ + default: ({ worktree, laneIndex }: { worktree: Worktree; laneIndex: number }) => ( +
    + ) +})) + +const { default: WorkspaceKanbanLaneCardList } = await import('./WorkspaceKanbanLaneCardList') + +const REPO_MAP = new Map() + +function renderLane(count: number): HTMLElement { + const scrollRef = createRef() + const { container } = render( +
    + ({ id: `w${index}` }) as Worktree)} + repoMap={REPO_MAP} + activeWorktreeId={null} + scrollRef={scrollRef} + selectedWorktreeIds={new Set()} + selectedWorktrees={[]} + nativeDragEnabled={false} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + /> +
    + ) + return container +} + +afterEach(cleanup) + +describe('WorkspaceKanbanLaneCardList', () => { + it('mounts only the virtual window, not the whole lane', () => { + const container = renderLane(500) + + const ids = Array.from(container.querySelectorAll('[data-workspace-board-card-id]')).map( + (card) => card.getAttribute('data-workspace-board-card-id') + ) + expect(ids).toEqual(['w20', 'w21', 'w22', 'w23', 'w24', 'w25']) + }) + + it('gives every rendered card its lane index, not its rendered position', () => { + const container = renderLane(500) + + const indexes = Array.from(container.querySelectorAll('[data-workspace-board-card-index]')).map( + (card) => Number(card.getAttribute('data-workspace-board-card-index')) + ) + expect(indexes).toEqual([20, 21, 22, 23, 24, 25]) + }) + + it('reserves the full lane height so the scrollbar spans the whole list', () => { + const container = renderLane(500) + + const spacer = container.querySelector('[data-workspace-board-card-id]') + ?.parentElement?.parentElement + expect(spacer?.style.height).toBe(`${TOTAL_SIZE}px`) + }) + + it('positions each card at its virtual offset', () => { + const container = renderLane(500) + + const first = container.querySelector( + '[data-workspace-board-card-id]' + )?.parentElement + expect(first?.style.transform).toBe(`translateY(${WINDOW_START * 44}px)`) + }) + + it('renders nothing for an empty lane and a single card at index 0', () => { + const empty = renderLane(0) + expect(empty.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(0) + + const single = renderLane(1) + const card = single.querySelector('[data-workspace-board-card-id]') + expect(card?.getAttribute('data-workspace-board-card-id')).toBe('w0') + expect(card?.getAttribute('data-workspace-board-card-index')).toBe('0') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx new file mode 100644 index 00000000000..49b9232839b --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneCardList.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' + +// Why: board cards are uniform one-line rows; a close estimate keeps the first +// virtual window right so the lane does not reflow once cards measure. +const WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT = 36 +// Matches the `space-y-2` rhythm the lane used before virtualization. +const WORKSPACE_BOARD_CARD_GAP = 8 +const WORKSPACE_BOARD_CARD_OVERSCAN = 6 + +function estimateWorkspaceBoardCardSize(): number { + return WORKSPACE_BOARD_CARD_ESTIMATED_HEIGHT +} + +type WorkspaceKanbanLaneCardListProps = { + items: readonly Worktree[] + repoMap: Map + activeWorktreeId: string | null + scrollRef: React.RefObject + selectedWorktreeIds: ReadonlySet + selectedWorktrees: readonly Worktree[] + nativeDragEnabled: boolean + onActivate: () => void + onSelectionGesture: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void +} + +function WorkspaceKanbanLaneCardList({ + items, + repoMap, + activeWorktreeId, + scrollRef, + selectedWorktreeIds, + selectedWorktrees, + nativeDragEnabled, + onActivate, + onSelectionGesture, + onContextMenuSelect, + onAssignWorkspaceStatus +}: WorkspaceKanbanLaneCardListProps): React.JSX.Element { + const spacerRef = useRef(null) + const itemIds = useMemo(() => items.map((item) => item.id), [items]) + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: estimateWorkspaceBoardCardSize, + getItemKey: useCallback((index: number) => items[index]?.id ?? index, [items]), + overscan: WORKSPACE_BOARD_CARD_OVERSCAN, + gap: WORKSPACE_BOARD_CARD_GAP, + // Why: sync-flushing rich card renders inside the scroll listener stalls the + // wheel; async + overscan keeps the lane filled without blocking input. + useFlushSync: false + }) + + useLayoutEffect(() => { + const scrollElement = scrollRef.current + const spacerElement = spacerRef.current + if (!scrollElement || !spacerElement) { + return + } + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => itemIds, + getMeasurements: () => virtualizer.measurementsCache + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- virtualizer is a stable instance (useVirtualizer holds it in useState), and getMeasurements reads measurementsCache off it live. + }, [itemIds, scrollRef]) + + return ( +
    + {virtualizer.getVirtualItems().map((virtualItem) => { + const worktree = items[virtualItem.index] + if (!worktree) { + return null + } + const isSelected = selectedWorktreeIds.has(worktree.id) + return ( +
    + 0 ? selectedWorktrees : undefined + } + onActivate={onActivate} + onSelectionGesture={onSelectionGesture} + onContextMenuSelect={onContextMenuSelect} + onAssignWorkspaceStatus={onAssignWorkspaceStatus} + /> +
    + ) + })} +
    + ) +} + +export default React.memo(WorkspaceKanbanLaneCardList) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 0b258d4dcf7..12506678b63 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -8,6 +8,9 @@ import type { import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' +// Why: a fresh [] per render would defeat the memoized lane on empty lanes. +const EMPTY_LANE_ITEMS: readonly Worktree[] = [] + type WorkspaceKanbanLaneGridProps = { statuses: readonly WorkspaceStatusDefinition[] laneViews: ReadonlyMap @@ -19,6 +22,7 @@ type WorkspaceKanbanLaneGridProps = { isResizingColumn: boolean dragOverStatus: WorkspaceStatus | null canCreateWorktree: boolean + renderCards: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -47,6 +51,7 @@ export default function WorkspaceKanbanLaneGrid({ isResizingColumn, dragOverStatus, canCreateWorktree, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -72,7 +77,7 @@ export default function WorkspaceKanbanLaneGrid({ ({ - default: ({ worktree }: { worktree: Worktree }) => ( -
    +vi.mock('./WorkspaceKanbanLaneCardList', () => ({ + default: ({ items }: { items: readonly Worktree[] }) => ( + <> + {items.map((item) => ( +
    + ))} + ) })) @@ -51,6 +55,7 @@ function renderLane(props: { isResizingColumn={false} isDragTarget={false} canCreateWorktree={true} + renderCards={true} selectedWorktreeIds={new Set()} selectedWorktrees={[]} onDragOver={vi.fn()} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index 40e9f1e8360..cceda5e12ee 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useRef } from 'react' import { Plus } from 'lucide-react' import type { Repo, @@ -13,7 +13,7 @@ import { import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import WorkspaceKanbanCard from './WorkspaceKanbanCard' +import WorkspaceKanbanLaneCardList from './WorkspaceKanbanLaneCardList' import { serializeWorkspaceLaneFullIds } from './workspace-kanban-filtered-drop-index' import { getWorkspaceStatusVisualMeta } from './workspace-status' import { translate } from '@/i18n/i18n' @@ -32,6 +32,7 @@ type WorkspaceKanbanStatusLaneProps = { isDragTarget: boolean canCreateWorktree: boolean nativeDragEnabled?: boolean + renderCards: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -49,7 +50,7 @@ type WorkspaceKanbanStatusLaneProps = { onColumnResizeKeyDown: (event: React.KeyboardEvent) => void } -export default function WorkspaceKanbanStatusLane({ +function WorkspaceKanbanStatusLane({ status, items, totalCount, @@ -62,6 +63,7 @@ export default function WorkspaceKanbanStatusLane({ isDragTarget, canCreateWorktree, nativeDragEnabled = true, + renderCards, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -75,6 +77,7 @@ export default function WorkspaceKanbanStatusLane({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanStatusLaneProps): React.JSX.Element { + const laneScrollRef = useRef(null) const meta = getWorkspaceStatusVisualMeta(status) // Why: a lane that is empty on its own merits is still "Empty" under a query — // only a lane whose cards were filtered away has anything to say about matches. @@ -180,32 +183,26 @@ export default function WorkspaceKanbanStatusLane({
    {items.length > 0 ? ( -
    - {items.map((worktree) => { - const isSelected = selectedWorktreeIds.has(worktree.id) - return ( - 0 ? selectedWorktrees : undefined - } - onActivate={onActivate} - onSelectionGesture={onSelectionGesture} - onContextMenuSelect={onContextMenuSelect} - onAssignWorkspaceStatus={onAssignWorkspaceStatus} - /> - ) - })} -
    + renderCards ? ( + + ) : null ) : (
    {isFiltered @@ -241,3 +238,5 @@ export default function WorkspaceKanbanStatusLane({
    ) } + +export default React.memo(WorkspaceKanbanStatusLane) diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts index a767e73fe95..36f4d5af69c 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts @@ -1,18 +1,20 @@ /* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */ import React, { useCallback, useEffect, useRef } from 'react' +import { + getAreaSelectionCardRects, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' import { clearPreviewSelection, getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - getAreaSelectionCardRects, getAreaSelectionRect, getAreaSelectionScrollContainer, getAreaSelectionScrollStartContentYByElement, isScrollbarPointerDown, setOverlayRect, shouldIgnoreAreaSelectionStart, - updatePreviewSelection, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' type AreaSelectionDragState = { @@ -110,6 +112,20 @@ export function useWorkspaceKanbanAreaSelection({ state.started = true + // Why: deferred card mount can start a marquee against empty rects; pick up + // virtual layout once lanes register without waiting for a scroll event. + if (state.cardRects.length === 0) { + const board = boardRef.current + if (board) { + state.boardRect = board.getBoundingClientRect() + state.cardRects = getAreaSelectionCardRects(board) + state.scrollStartContentYByElement = getAreaSelectionScrollStartContentYByElement( + board, + state.startY + ) + } + } + const viewportRect = getAreaSelectionRect( state.startX, state.startY, @@ -153,7 +169,7 @@ export function useWorkspaceKanbanAreaSelection({ state.additive, areaIds ) - }, [overlayRef]) + }, [boardRef, overlayRef]) const refreshAreaSelectionMeasurements = useCallback(() => { const state = dragRef.current @@ -233,6 +249,7 @@ export function useWorkspaceKanbanAreaSelection({ window.cancelAnimationFrame(state.scrollFrameId) state.scrollFrameId = null } + refreshAreaSelectionMeasurements() flushAreaSelectionDrag() if (shouldCommitWorkspaceKanbanAreaSelection(state)) { updateSelectionForAreaRef.current( @@ -246,7 +263,7 @@ export function useWorkspaceKanbanAreaSelection({ dragRef.current = null setOverlayRect(overlayRef.current, null) }, - [flushAreaSelectionDrag, overlayRef] + [flushAreaSelectionDrag, overlayRef, refreshAreaSelectionMeasurements] ) const handleAreaSelectionPointerDown = useCallback( diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts index 14f635896fc..19a67a31663 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-card-pointer-drag.ts @@ -11,7 +11,6 @@ import { } from './workspace-kanban-card-pointer-drag-dom' import { createDragPreview, - getDraggedCards, setDragDocumentStyles, setDraggedCardsDragging, updateDragPreviewPosition @@ -33,7 +32,6 @@ type DragState = { currentY: number worktreeIds: string[] sourceCard: HTMLElement - draggedCards: HTMLElement[] preview: HTMLElement | null previewOffsetX: number previewOffsetY: number @@ -117,7 +115,11 @@ export function useWorkspaceKanbanCardPointerDrag({ if (state.frameId !== null) { window.cancelAnimationFrame(state.frameId) } - setDraggedCardsDragging(state.draggedCards, false) + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: false + }) removeCardDropIndicator() state.preview?.remove() setDragDocumentStyles(false) @@ -146,13 +148,20 @@ export function useWorkspaceKanbanCardPointerDrag({ [boardRef, clearDragTarget] ) - const startPointerDrag = useCallback((state: DragState) => { - state.started = true - isPointerDragActiveRef.current = true - setDraggedCardsDragging(state.draggedCards, true) - state.preview = createDragPreview(state) - setDragDocumentStyles(true) - }, []) + const startPointerDrag = useCallback( + (state: DragState) => { + state.started = true + isPointerDragActiveRef.current = true + setDraggedCardsDragging({ + board: boardRef.current, + worktreeIds: state.worktreeIds, + enabled: true + }) + state.preview = createDragPreview(state) + setDragDocumentStyles(true) + }, + [boardRef] + ) const updatePointerDragTarget = useCallback( (state: DragState) => { @@ -289,10 +298,9 @@ export function useWorkspaceKanbanCardPointerDrag({ return } - const selectedIds = selectedWorktreeIdsRef.current const selectedWorktrees = selectedWorktreesRef.current const worktreeIds = - selectedIds.has(worktreeId) && selectedWorktrees.length > 1 + selectedWorktreeIdsRef.current.has(worktreeId) && selectedWorktrees.length > 1 ? selectedWorktrees.map((worktree) => worktree.id) : [worktreeId] dragRef.current = { @@ -303,7 +311,6 @@ export function useWorkspaceKanbanCardPointerDrag({ currentY: event.clientY, worktreeIds, sourceCard: card, - draggedCards: getDraggedCards(board, worktreeIds, card), preview: null, previewOffsetX: 0, previewOffsetY: 0, diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts new file mode 100644 index 00000000000..95d315966f2 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-card-rects.ts @@ -0,0 +1,99 @@ +import { getWorkspaceKanbanVirtualLaneItemRects } from './workspace-kanban-virtual-lane-layout' + +type AreaSelectionCardContentRect = { + top: number + bottom: number + containerTop: number + scrollTop: number +} + +export type AreaSelectionViewportRect = { + left: number + top: number + right: number + bottom: number +} + +export type AreaSelectionCardRect = { + id: string + element: HTMLElement | null + rect: AreaSelectionViewportRect + scrollContainer: HTMLElement | null + contentRect: AreaSelectionCardContentRect | null +} + +export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' + +export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { + const cardRects = new Map() + const scrollMetrics = new Map() + const scrollContainers = board.querySelectorAll( + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR + ) + for (const scrollContainer of scrollContainers) { + const virtualRects = getWorkspaceKanbanVirtualLaneItemRects(scrollContainer) + if (!virtualRects) { + continue + } + const containerTop = scrollContainer.getBoundingClientRect().top + const scrollTop = scrollContainer.scrollTop + scrollMetrics.set(scrollContainer, { containerTop, scrollTop }) + for (const virtualRect of virtualRects) { + cardRects.set(virtualRect.id, { + id: virtualRect.id, + element: null, + rect: { + left: virtualRect.left, + top: virtualRect.top, + right: virtualRect.right, + bottom: virtualRect.bottom + }, + scrollContainer, + contentRect: { + top: virtualRect.contentTop, + bottom: virtualRect.contentBottom, + containerTop, + scrollTop + } + }) + } + } + + const cards = board.querySelectorAll('[data-workspace-board-card-id]') + for (const card of cards) { + const id = card.dataset.workspaceBoardCardId + if (!id) { + continue + } + const rect = card.getBoundingClientRect() + const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) + let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined + if (scrollContainer && !metrics) { + metrics = { + containerTop: scrollContainer.getBoundingClientRect().top, + scrollTop: scrollContainer.scrollTop + } + scrollMetrics.set(scrollContainer, metrics) + } + cardRects.set(id, { + id, + element: card, + rect: { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom + }, + scrollContainer, + contentRect: metrics + ? { + top: rect.top - metrics.containerTop + metrics.scrollTop, + bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, + containerTop: metrics.containerTop, + scrollTop: metrics.scrollTop + } + : null + }) + } + return Array.from(cardRects.values()) +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts index a0a5c9d46b3..d023c468fbd 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-dom.ts @@ -1,3 +1,8 @@ +import { + AREA_SELECTION_SCROLL_CONTAINER_SELECTOR, + type AreaSelectionCardRect +} from './workspace-kanban-area-selection-card-rects' + export type AreaSelectionRect = { left: number top: number @@ -5,21 +10,6 @@ export type AreaSelectionRect = { height: number } -export type AreaSelectionCardRect = { - id: string - element: HTMLElement - rect: DOMRect - scrollContainer: HTMLElement | null - contentRect: AreaSelectionCardContentRect | null -} - -type AreaSelectionCardContentRect = { - top: number - bottom: number - containerTop: number - scrollTop: number -} - type AreaSelectionAutoScrollParams = { pointerY: number containerTop: number @@ -37,7 +27,6 @@ type AreaSelectionCardIdOptions = { } const AREA_SELECTED_ATTR = 'data-workspace-board-card-area-selected' -export const AREA_SELECTION_SCROLL_CONTAINER_SELECTOR = '[data-workspace-board-lane-scroll]' export const AREA_SELECTION_AUTO_SCROLL_EDGE_SIZE = 48 export const AREA_SELECTION_AUTO_SCROLL_MAX_DELTA = 22 @@ -93,45 +82,6 @@ export function isScrollbarPointerDown( return hitsVerticalScrollbar || hitsHorizontalScrollbar } -export function getAreaSelectionCardRects(board: HTMLElement): AreaSelectionCardRect[] { - const cardRects: AreaSelectionCardRect[] = [] - const seen = new Set() - const scrollMetrics = new Map() - const cards = board.querySelectorAll('[data-workspace-board-card-id]') - for (const card of cards) { - const id = card.dataset.workspaceBoardCardId - if (!id || seen.has(id)) { - continue - } - const rect = card.getBoundingClientRect() - const scrollContainer = card.closest(AREA_SELECTION_SCROLL_CONTAINER_SELECTOR) - let metrics = scrollContainer ? scrollMetrics.get(scrollContainer) : undefined - if (scrollContainer && !metrics) { - metrics = { - containerTop: scrollContainer.getBoundingClientRect().top, - scrollTop: scrollContainer.scrollTop - } - scrollMetrics.set(scrollContainer, metrics) - } - cardRects.push({ - id, - element: card, - rect, - scrollContainer, - contentRect: metrics - ? { - top: rect.top - metrics.containerTop + metrics.scrollTop, - bottom: rect.bottom - metrics.containerTop + metrics.scrollTop, - containerTop: metrics.containerTop, - scrollTop: metrics.scrollTop - } - : null - }) - seen.add(id) - } - return cardRects -} - export function getAreaSelectionCardIds( cardRects: readonly AreaSelectionCardRect[], selectionRect: AreaSelectionRect, @@ -256,7 +206,8 @@ export function clearPreviewSelection( previewIds: Set ): void { for (const card of cardRects) { - if (previewIds.has(card.id)) { + // Why: virtual remounts replace the element; clear via live connected node when present. + if (card.element?.isConnected && previewIds.has(card.id)) { card.element.removeAttribute(AREA_SELECTED_ATTR) } } @@ -276,16 +227,29 @@ export function updatePreviewSelection( } for (const card of cardRects) { + const element = card.element?.isConnected ? card.element : null + if (!element) { + if (!nextIds.has(card.id)) { + previewIds.delete(card.id) + } + continue + } const shouldPreview = nextIds.has(card.id) - const isPreviewed = previewIds.has(card.id) + // Why: virtualization can remount the same card id; trust the live attr, not previewIds alone. + const isPreviewed = element.getAttribute(AREA_SELECTED_ATTR) === 'true' if (shouldPreview === isPreviewed) { + if (shouldPreview) { + previewIds.add(card.id) + } else { + previewIds.delete(card.id) + } continue } if (shouldPreview) { - card.element.setAttribute(AREA_SELECTED_ATTR, 'true') + element.setAttribute(AREA_SELECTED_ATTR, 'true') previewIds.add(card.id) } else { - card.element.removeAttribute(AREA_SELECTED_ATTR) + element.removeAttribute(AREA_SELECTED_ATTR) previewIds.delete(card.id) } } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts index d04f34ca121..86a51e1e8a2 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts @@ -1,8 +1,10 @@ +// @vitest-environment happy-dom import { describe, expect, it } from 'vitest' +import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects' import { getAreaSelectionAutoScrollDelta, getAreaSelectionCardIds, - type AreaSelectionCardRect + updatePreviewSelection } from './workspace-kanban-area-selection-dom' import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' @@ -166,16 +168,52 @@ function makeRect({ top: number right: number bottom: number -}): DOMRect { - return { - left, - top, - right, - bottom, - width: right - left, - height: bottom - top, - x: left, - y: top, - toJSON: () => ({}) - } as DOMRect +}): AreaSelectionCardRect['rect'] { + return { left, top, right, bottom } } + +describe('workspace kanban area selection preview remount', () => { + it('re-applies the preview attribute when a card remounts under the same id', () => { + const first = document.createElement('div') + first.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(first, 'isConnected', { value: true }) + const previewIds = new Set() + updatePreviewSelection( + [ + { + id: 'card-a', + element: first, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(first.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + expect(previewIds.has('card-a')).toBe(true) + + const remounted = document.createElement('div') + remounted.setAttribute('data-workspace-board-card-id', 'card-a') + Object.defineProperty(remounted, 'isConnected', { value: true }) + updatePreviewSelection( + [ + { + id: 'card-a', + element: remounted, + rect: makeRect({ left: 0, top: 0, right: 100, bottom: 40 }), + scrollContainer: null, + contentRect: null + } + ], + previewIds, + new Set(), + false, + ['card-a'] + ) + expect(remounted.getAttribute('data-workspace-board-card-area-selected')).toBe('true') + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts index 887d7ebcac2..58dafc5db3f 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-drag-preview-dom.ts @@ -37,12 +37,27 @@ export function getDraggedCards( return cards.length > 0 ? cards : [fallbackCard] } -export function setDraggedCardsDragging(cards: readonly HTMLElement[], enabled: boolean): void { - for (const card of cards) { - if (enabled) { - card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') - } else { +export function setDraggedCardsDragging(args: { + board: HTMLElement | null + worktreeIds: readonly string[] + enabled: boolean +}): void { + const { board, worktreeIds, enabled } = args + if (!board) { + return + } + // Why: virtual lanes remount cards during scroll; query live nodes by id + // instead of holding HTMLElement references across unmounts. + if (!enabled) { + board.querySelectorAll(`[${POINTER_CARD_DRAGGING_ATTR}]`).forEach((card) => { card.removeAttribute(POINTER_CARD_DRAGGING_ATTR) + }) + return + } + const ids = new Set(worktreeIds) + for (const card of board.querySelectorAll(CARD_SELECTOR)) { + if (ids.has(card.dataset.workspaceBoardCardId ?? '')) { + card.setAttribute(POINTER_CARD_DRAGGING_ATTR, 'true') } } } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts index 5a8698a1cb8..f8ab1df0f94 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveWorkspaceKanbanCardDropCommitTarget, resolveWorkspaceCardDropIndexFromRects, + resolveWorkspaceCardDropIndicatorY, resolveWorkspaceStatusDropTargetFromRects } from './workspace-kanban-card-pointer-drag-dom' @@ -41,6 +42,47 @@ describe('workspace kanban pointer drag card drop index', () => { it('inserts at the end when the pointer is below every card midpoint', () => { expect(resolveWorkspaceCardDropIndexFromRects(cardRects, 140)).toBe(3) }) + + // Why: a virtualized lane only renders its window, so rendered position is + // not lane position. Without the carried index a drop into a scrolled lane + // would land near the top of the list. + it('uses the lane index of a rendered card rather than its rendered position', () => { + const virtualized = [ + { top: 0, bottom: 40, index: 20 }, + { top: 48, bottom: 88, index: 21 }, + { top: 96, bottom: 136, index: 22 } + ] + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 10)).toBe(20) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 70)).toBe(22) + expect(resolveWorkspaceCardDropIndexFromRects(virtualized, 140)).toBe(23) + }) +}) + +describe('workspace kanban drop indicator placement', () => { + const cardRects = [ + { top: 100, bottom: 140 }, + { top: 148, bottom: 188 } + ] + + it('places the line above, between, and below rendered cards', () => { + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 0, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 1, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(cardRects, 2, 90)).toBe(193) + }) + + it('falls back to the lane top when the lane renders no cards', () => { + expect(resolveWorkspaceCardDropIndicatorY([], 0, 90)).toBe(104) + }) + + it('anchors to the rendered card that owns the lane index', () => { + const virtualized = [ + { top: 100, bottom: 140, index: 20 }, + { top: 148, bottom: 188, index: 21 } + ] + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 20, 90)).toBe(95) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 21, 90)).toBe(144) + expect(resolveWorkspaceCardDropIndicatorY(virtualized, 22, 90)).toBe(193) + }) }) describe('workspace kanban pointer drag commit target', () => { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts index 9114a47a881..dd59d430ab9 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-card-pointer-drag-dom.ts @@ -1,4 +1,8 @@ import type { WorkspaceStatus } from '../../../../shared/types' +import { + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' export const CARD_SELECTOR = '[data-workspace-board-card-id]' export const STATUS_DROP_TARGET = '[data-workspace-status-drop-target]' @@ -19,6 +23,9 @@ export type WorkspaceKanbanStatusDropRect = { export type WorkspaceKanbanCardDropRect = { top: number bottom: number + // Why: lanes virtualize, so a rendered card's DOM position is not its index + // in the lane. Carry the lane index when the card advertises one. + index?: number } export type WorkspaceKanbanLaneDropRect = { @@ -31,6 +38,7 @@ export type WorkspaceKanbanCardDropTarget = { status: WorkspaceStatus | null isPinDrop: boolean dropIndex: number + dropIndicatorY?: number laneRect?: WorkspaceKanbanLaneDropRect cardRects?: readonly WorkspaceKanbanCardDropRect[] } @@ -75,10 +83,51 @@ export function resolveWorkspaceCardDropIndexFromRects( for (let index = 0; index < rects.length; index++) { const rect = rects[index]! if (y < (rect.top + rect.bottom) / 2) { - return index + return rect.index ?? index } } - return rects.length + const last = rects.at(-1) + return last?.index !== undefined ? last.index + 1 : rects.length +} + +function getRenderedCardLanePosition(rect: WorkspaceKanbanCardDropRect, fallback: number): number { + return rect.index ?? fallback +} + +export function resolveWorkspaceCardDropIndicatorY( + rects: readonly WorkspaceKanbanCardDropRect[], + dropIndex: number, + laneTop: number +): number { + if (rects.length === 0) { + return laneTop + 14 + } + // Why: with a virtualized lane the drop index counts unrendered cards too, + // so place the line relative to the rendered card that owns that slot. + const slot = rects.findIndex( + (rect, index) => getRenderedCardLanePosition(rect, index) >= dropIndex + ) + if (slot === -1) { + return rects.at(-1)!.bottom + 5 + } + if (slot === 0) { + return rects[0]!.top - 5 + } + return (rects[slot - 1]!.bottom + rects[slot]!.top) / 2 +} + +function getLaneCardDropRects(lane: HTMLElement): WorkspaceKanbanCardDropRect[] { + return Array.from(lane.querySelectorAll(CARD_SELECTOR)) + .filter((card) => card.offsetParent !== null) + .map((card) => { + const rect = card.getBoundingClientRect() + const index = Number.parseInt(card.dataset.workspaceBoardCardIndex ?? '', 10) + return { + top: rect.top, + bottom: rect.bottom, + ...(Number.isInteger(index) ? { index } : {}) + } + }) } function hasWorkspaceKanbanCardDropTarget(target: WorkspaceKanbanCardDropTarget): boolean { @@ -176,16 +225,18 @@ export function getCardDropTarget( const laneContent = lane.querySelector('[data-workspace-board-lane-scroll]') const laneClientRect = (laneContent ?? lane).getBoundingClientRect() - const cardRects = Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const dropIndex = resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const cardRects = getLaneCardDropRects(lane) + const virtualDropIndex = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndex(laneContent, y) + : null + const dropIndex = virtualDropIndex ?? resolveWorkspaceCardDropIndexFromRects(cardRects, y) + const dropIndicatorY = laneContent + ? resolveWorkspaceKanbanVirtualLaneDropIndicatorY(laneContent, dropIndex) + : null return { ...target, dropIndex, + ...(dropIndicatorY !== null ? { dropIndicatorY } : {}), laneRect: { left: laneClientRect.left, top: laneClientRect.top, @@ -245,23 +296,10 @@ export function updateCardDropIndicator( width: fallbackLaneRect.width } : { left: 0, top: 0, width: 0 }) - const cardRects = - target.cardRects ?? - Array.from(lane.querySelectorAll(CARD_SELECTOR)) - .filter((card) => card.offsetParent !== null) - .map((card) => { - const rect = card.getBoundingClientRect() - return { top: rect.top, bottom: rect.bottom } - }) - const boundedDropIndex = Math.max(0, Math.min(cardRects.length, target.dropIndex)) + const cardRects = target.cardRects ?? getLaneCardDropRects(lane) const y = - cardRects.length === 0 - ? laneRect.top + 14 - : boundedDropIndex === 0 - ? cardRects[0]!.top - 5 - : boundedDropIndex >= cardRects.length - ? cardRects.at(-1)!.bottom + 5 - : (cardRects[boundedDropIndex - 1]!.bottom + cardRects[boundedDropIndex]!.top) / 2 + target.dropIndicatorY ?? + resolveWorkspaceCardDropIndicatorY(cardRects, Math.max(0, target.dropIndex), laneRect.top) const horizontalInset = 8 const indicator = getOrCreateDropIndicator() indicator.dataset.workspaceStatus = target.status diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 3feefdd62da..08f627cc0b4 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -7,9 +7,11 @@ import { getWorkspaceKanbanSidebarDropGroups, getWorkspaceKanbanSidebarDropTarget, isWorkspaceKanbanSidebarDropPointInBoard, + registerWorkspaceKanbanSidebarDropGroups, resolveWorkspaceKanbanSidebarFullLaneDropIndex, updateWorkspaceKanbanSidebarDropTargetVisual } from './workspace-kanban-sidebar-drop' +import { registerWorkspaceKanbanVirtualLaneLayout } from './workspace-kanban-virtual-lane-layout' const workspaceStatuses: WorkspaceStatusDefinition[] = [ { id: 'todo', label: 'Todo' }, @@ -29,6 +31,7 @@ class FakeElement extends FakeNode { } } offsetParent: FakeElement | null = null + scrollTop = 0 private readonly attributes = new Map() private rect: Pick = { left: 0, @@ -159,6 +162,7 @@ class FakeDocument { } let fakeDocument: FakeDocument +let unregisterSidebarDropGroups: (() => void) | null = null function setRect( element: HTMLElement, @@ -221,6 +225,8 @@ function worktree(args: { } afterEach(() => { + unregisterSidebarDropGroups?.() + unregisterSidebarDropGroups = null clearWorkspaceKanbanSidebarDropTargetVisual() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -249,6 +255,71 @@ describe('workspace kanban sidebar drop DOM bridge', () => { ]) }) + it('prefers complete registered groups and restores the DOM fallback on cleanup', () => { + appendBoard() + unregisterSidebarDropGroups = registerWorkspaceKanbanSidebarDropGroups([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b', 'doing-c', 'doing-d'] } + ]) + + unregisterSidebarDropGroups() + unregisterSidebarDropGroups = null + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-b'] } + ]) + }) + + it('uses the full virtual layout when the mounted card window is stale', () => { + const { lane } = appendBoard() + const laneScroll = document.createElement('div') + laneScroll.setAttribute('data-workspace-board-lane-scroll', '') + setRect(laneScroll, { + left: 0, + top: 0, + right: 200, + bottom: 220, + width: 200, + height: 220 + }) + const spacer = document.createElement('div') + setRect(spacer, { + left: 8, + top: -4260, + right: 192, + bottom: 220, + width: 184, + height: 4480 + }) + laneScroll.append(spacer) + lane.append(laneScroll) + setElementFromPoint(lane) + const unregister = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement: laneScroll, + spacerElement: spacer, + getItemIds: () => Array.from({ length: 102 }, (_, index) => `doing-${index}`), + getMeasurements: () => + Array.from({ length: 102 }, (_, index) => ({ + index, + start: index * 44, + end: index * 44 + 36 + })) + }) + + expect(getWorkspaceKanbanSidebarDropTarget(24, 210)).toMatchObject({ + status: 'doing', + dropIndex: 102, + dropIndicatorY: 225, + cardRects: [ + { top: 8, bottom: 48 }, + { top: 56, bottom: 96 } + ] + }) + unregister() + }) + it('prefers the published full lane membership over the rendered card scan', () => { const { lane } = appendBoard() lane.dataset.workspaceLaneFullIds = diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index c48d5312ad1..363038bd529 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -29,6 +29,7 @@ const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null +let logicalDropGroupsRegistration: { groups: readonly WorktreeDragGroup[] } | null = null function getWorkspaceKanbanBoardElement(): HTMLElement | null { return document.querySelector(BOARD_SELECTOR) @@ -97,7 +98,26 @@ export function clearWorkspaceKanbanSidebarDropTargetVisual(): void { removeCardDropIndicator() } +export function registerWorkspaceKanbanSidebarDropGroups( + groups: readonly WorktreeDragGroup[] +): () => void { + const registration = { groups } + logicalDropGroupsRegistration = registration + return () => { + if (logicalDropGroupsRegistration === registration) { + logicalDropGroupsRegistration = null + } + } +} + export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { + if (logicalDropGroupsRegistration) { + return logicalDropGroupsRegistration.groups.map((group) => ({ + key: group.key, + worktreeIds: [...group.worktreeIds] + })) + } + const board = getWorkspaceKanbanBoardElement() if (!board) { return [] diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts new file mode 100644 index 00000000000..003cdf73784 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects' +import { getAreaSelectionCardIds } from './workspace-kanban-area-selection-dom' +import { + getWorkspaceKanbanVirtualLaneItemRects, + registerWorkspaceKanbanVirtualLaneLayout, + resolveWorkspaceKanbanVirtualLaneDropIndex, + resolveWorkspaceKanbanVirtualLaneDropIndicatorY +} from './workspace-kanban-virtual-lane-layout' + +const ITEM_COUNT = 102 +const ITEM_STRIDE = 44 +const ITEM_HEIGHT = 36 + +function createLayoutElements(): { + scrollElement: HTMLElement + spacerElement: HTMLElement +} { + const scrollElement = { + scrollTop: 0, + getBoundingClientRect: () => ({ + left: 20, + top: 100, + right: 220, + bottom: 600, + width: 200, + height: 500 + }) + } as unknown as HTMLElement + const spacerElement = { + getBoundingClientRect: () => ({ + left: 26, + top: 108 - scrollElement.scrollTop, + right: 214, + bottom: 108 - scrollElement.scrollTop + ITEM_COUNT * ITEM_STRIDE, + width: 188, + height: ITEM_COUNT * ITEM_STRIDE + }) + } as unknown as HTMLElement + return { scrollElement, spacerElement } +} + +function registerLayout(scrollElement: HTMLElement, spacerElement: HTMLElement): () => void { + return registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => Array.from({ length: ITEM_COUNT }, (_, index) => `w${index}`), + getMeasurements: () => + Array.from({ length: ITEM_COUNT }, (_, index) => ({ + index, + start: index * ITEM_STRIDE, + end: index * ITEM_STRIDE + ITEM_HEIGHT + })) + }) +} + +describe('workspace kanban virtual lane layout', () => { + it('resolves mid-lane and bottom drops immediately after a large scroll jump', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 370)).toBe(97) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 590)).toBe(102) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 102)).toBe(613) + unregister() + }) + + it('exposes every logical item even when no card is mounted', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + + const rects = getWorkspaceKanbanVirtualLaneItemRects(scrollElement) + expect(rects).toHaveLength(ITEM_COUNT) + expect(rects?.[50]).toMatchObject({ + id: 'w50', + index: 50, + contentTop: 2_208, + contentBottom: 2_244 + }) + unregister() + }) + + it('selects intermediate never-mounted cards across a jumped lane range', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregister = registerLayout(scrollElement, spacerElement) + scrollElement.scrollTop = 3_980 + const board = { + querySelectorAll: (selector: string) => + selector === '[data-workspace-board-lane-scroll]' ? [scrollElement] : [] + } as unknown as HTMLElement + + const cardRects = getAreaSelectionCardRects(board) + const selectedIds = getAreaSelectionCardIds( + cardRects, + { left: 0, top: 110, width: 300, height: 480 }, + { + scrollStartContentYByElement: new Map([[scrollElement, 18]]), + currentY: 590 + } + ) + + expect(cardRects.every((card) => card.element === null)).toBe(true) + expect(selectedIds).toHaveLength(ITEM_COUNT) + expect(selectedIds).toContain('w50') + unregister() + }) + + it('does not let stale cleanup remove a newer lane registration', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterFirst = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['old'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + const unregisterSecond = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['new'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + + unregisterFirst() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)?.map((item) => item.id)).toEqual([ + 'new' + ]) + unregisterSecond() + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + }) + + it('places empty-lane and boundary drop indicators from the spacer', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterEmpty = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => [], + getMeasurements: () => [] + }) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 200)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(114) + unregisterEmpty() + + const unregister = registerLayout(scrollElement, spacerElement) + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 90)).toBe(0) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 0)).toBe(103) + expect(resolveWorkspaceKanbanVirtualLaneDropIndicatorY(scrollElement, 1)).toBe(148) + unregister() + }) + + it('returns null when measurements are incomplete or mismatched', () => { + const { scrollElement, spacerElement } = createLayoutElements() + const unregisterShort = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a', 'b'], + getMeasurements: () => [{ index: 0, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + expect(resolveWorkspaceKanbanVirtualLaneDropIndex(scrollElement, 120)).toBeNull() + unregisterShort() + + const unregisterBadIndex = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement, + spacerElement, + getItemIds: () => ['a'], + getMeasurements: () => [{ index: 3, start: 0, end: ITEM_HEIGHT }] + }) + expect(getWorkspaceKanbanVirtualLaneItemRects(scrollElement)).toBeNull() + unregisterBadIndex() + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts new file mode 100644 index 00000000000..e142b3617d6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts @@ -0,0 +1,159 @@ +import type { VirtualItem } from '@tanstack/react-virtual' + +type VirtualLaneLayoutRegistration = { + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +} + +type VirtualLaneLayoutSnapshot = { + registration: VirtualLaneLayoutRegistration + itemIds: readonly string[] + measurements: readonly Pick[] +} + +export type WorkspaceKanbanVirtualLaneItemRect = { + id: string + index: number + left: number + top: number + right: number + bottom: number + contentTop: number + contentBottom: number +} + +const virtualLaneLayouts = new WeakMap() + +export function registerWorkspaceKanbanVirtualLaneLayout(args: { + scrollElement: HTMLElement + spacerElement: HTMLElement + getItemIds: () => readonly string[] + getMeasurements: () => readonly Pick[] +}): () => void { + const registration = { + spacerElement: args.spacerElement, + getItemIds: args.getItemIds, + getMeasurements: args.getMeasurements + } + virtualLaneLayouts.set(args.scrollElement, registration) + return () => { + if (virtualLaneLayouts.get(args.scrollElement) === registration) { + virtualLaneLayouts.delete(args.scrollElement) + } + } +} + +export function getWorkspaceKanbanVirtualLaneItemRects( + scrollElement: HTMLElement +): WorkspaceKanbanVirtualLaneItemRect[] | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerRect = snapshot.registration.spacerElement.getBoundingClientRect() + const containerRect = scrollElement.getBoundingClientRect() + const contentOffset = spacerRect.top - containerRect.top + scrollElement.scrollTop + const rects: WorkspaceKanbanVirtualLaneItemRect[] = [] + for (let index = 0; index < snapshot.itemIds.length; index++) { + const measurement = snapshot.measurements[index] + if (!isValidMeasurement(measurement, index)) { + return null + } + rects.push({ + id: snapshot.itemIds[index]!, + index, + left: spacerRect.left, + top: spacerRect.top + measurement.start, + right: spacerRect.right, + bottom: spacerRect.top + measurement.end, + contentTop: contentOffset + measurement.start, + contentBottom: contentOffset + measurement.end + }) + } + return rects +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndex( + scrollElement: HTMLElement, + pointerY: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + let low = 0 + let high = snapshot.itemIds.length + while (low < high) { + const middle = Math.floor((low + high) / 2) + const measurement = snapshot.measurements[middle] + if (!isValidMeasurement(measurement, middle)) { + return null + } + if (pointerY < spacerTop + (measurement.start + measurement.end) / 2) { + high = middle + } else { + low = middle + 1 + } + } + return low +} + +export function resolveWorkspaceKanbanVirtualLaneDropIndicatorY( + scrollElement: HTMLElement, + dropIndex: number +): number | null { + const snapshot = getVirtualLaneLayoutSnapshot(scrollElement) + if (!snapshot) { + return null + } + if (snapshot.itemIds.length === 0) { + return scrollElement.getBoundingClientRect().top + 14 + } + + const spacerTop = snapshot.registration.spacerElement.getBoundingClientRect().top + const boundedIndex = Math.max(0, Math.min(snapshot.itemIds.length, dropIndex)) + if (boundedIndex === 0) { + const first = snapshot.measurements[0] + return isValidMeasurement(first, 0) ? spacerTop + first.start - 5 : null + } + if (boundedIndex === snapshot.itemIds.length) { + const lastIndex = snapshot.itemIds.length - 1 + const last = snapshot.measurements[lastIndex] + return isValidMeasurement(last, lastIndex) ? spacerTop + last.end + 5 : null + } + const previous = snapshot.measurements[boundedIndex - 1] + const next = snapshot.measurements[boundedIndex] + if (!isValidMeasurement(previous, boundedIndex - 1) || !isValidMeasurement(next, boundedIndex)) { + return null + } + return spacerTop + (previous.end + next.start) / 2 +} + +function getVirtualLaneLayoutSnapshot( + scrollElement: HTMLElement +): VirtualLaneLayoutSnapshot | null { + const registration = virtualLaneLayouts.get(scrollElement) + if (!registration) { + return null + } + const itemIds = registration.getItemIds() + const measurements = registration.getMeasurements() + return measurements.length >= itemIds.length ? { registration, itemIds, measurements } : null +} + +function isValidMeasurement( + measurement: Pick | undefined, + index: number +): measurement is Pick { + return Boolean( + measurement && + measurement.index === index && + Number.isFinite(measurement.start) && + Number.isFinite(measurement.end) && + measurement.end >= measurement.start + ) +} diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts new file mode 100644 index 00000000000..850a83024a7 --- /dev/null +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -0,0 +1,238 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const SEEDED_WORKSPACE_COUNT = 300 +const MARQUEE_WORKSPACE_COUNT = 102 + +/** + * Why: the board used to mount every workspace card in every lane in one + * commit, which blocked the sheet open for seconds on a large workspace set. + * These assert the lane renders a window instead, and that each rendered card + * still carries its true lane index so drop targeting stays correct. + */ +test.describe('Workspace board lane virtualization', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('mounts a window of cards for a large lane and keeps lane indexes', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => { + const suffix = String(index).padStart(3, '0') + return { + id: `${repo.id}::/virtual-board-${suffix}`, + instanceId: `virtual-board-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-board-${suffix}`, + displayName: `Virtual board ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-board-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + } + }) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + // Why: an empty window would also satisfy "fewer than seeded"; the point of + // the change is a filled window, not a blank board. + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + expect(await cards.count()).toBeLessThan(SEEDED_WORKSPACE_COUNT / 2) + + const indexes = await cards.evaluateAll((elements) => + elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + expect(indexes.every((index) => Number.isInteger(index) && index >= 0)).toBe(true) + expect(new Set(indexes).size).toBe(indexes.length) + }) + + test('renders later lane indexes after the lane scrolls', async ({ orcaPage }) => { + await orcaPage.evaluate((count) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-scroll-${index}`, + instanceId: `virtual-scroll-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-scroll-${index}`, + displayName: `Virtual scroll ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-scroll-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: 'in-progress' + })) + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + }, SEEDED_WORKSPACE_COUNT) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const cards = orcaPage.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const readMaxIndex = (): Promise => + cards.evaluateAll((elements) => + Math.max( + ...elements.map((element) => + Number((element as HTMLElement).dataset.workspaceBoardCardIndex ?? -1) + ) + ) + ) + const before = await readMaxIndex() + + await orcaPage + .locator('[data-workspace-status="in-progress"] [data-workspace-board-lane-scroll]') + .first() + .evaluate((element) => { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before) + }) + + test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { + const statusId = 'virtual-marquee' + await orcaPage.evaluate( + ({ count, status }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const seeded = state.worktreesByRepo[repo.id] ?? [] + const synthetic = Array.from({ length: count }, (_, index) => ({ + id: `${repo.id}::/virtual-marquee-${index}`, + instanceId: `virtual-marquee-${index}`, + repoId: repo.id, + path: `${repo.path}/../virtual-marquee-${index}`, + displayName: `Virtual marquee ${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10_000 - index, + manualOrder: 10_000 - index, + lastActivityAt: now - index - 100, + head: '0000000000000000000000000000000000000000', + branch: `virtual-marquee-${index}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + })) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setFilterRepoIds([]) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: [...seeded, ...synthetic] } + }) + state.setWorkspaceStatuses([ + { id: status, label: 'Virtual marquee' }, + ...state.workspaceStatuses.filter((entry) => entry.id !== status) + ]) + }, + { count: MARQUEE_WORKSPACE_COUNT, status: statusId } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const lane = orcaPage.locator(`[data-workspace-status="${statusId}"]`) + await expect(lane.getByText(String(MARQUEE_WORKSPACE_COUNT), { exact: true })).toBeVisible() + const laneCards = lane.locator('[data-workspace-board-card-id]') + await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') + const box = await laneScroll.boundingBox() + if (!box) { + throw new Error('Expected the marquee lane to have a bounding box') + } + + await orcaPage.mouse.move(box.x + 2, box.y + 12) + await orcaPage.mouse.down() + await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80) + await laneScroll.evaluate(async (element) => { + for (let pass = 0; pass < 4; pass++) { + element.scrollTop = element.scrollHeight + element.dispatchEvent(new Event('scroll', { bubbles: true })) + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + } + }) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + box.height - 12) + await orcaPage.mouse.up() + + await expect( + orcaPage.getByText(`${MARQUEE_WORKSPACE_COUNT} selected`, { exact: true }) + ).toBeVisible() + }) +}) From 8fef6db29266f57a450fafaa9d2d26554d099113 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:32:40 -0700 Subject: [PATCH 039/123] Fix duplicate remote worktree delete failures (#11623) * Fix duplicate worktree delete attempts * Complete delete flow selector mock * Normalize duplicate delete selections --- .../sidebar/DeleteWorktreeDialog.test.tsx | 4 +++ .../sidebar/DeleteWorktreeDialog.tsx | 9 ++++--- .../sidebar/delete-worktree-flow.test.ts | 11 ++++++++ .../sidebar/delete-worktree-flow.ts | 25 +++++++++++-------- .../delete-worktree-parallel-flow.test.ts | 20 +++++++++++++++ 5 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 7f1576c720b..3106deda4c7 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -42,6 +42,10 @@ vi.mock('@/store', () => ({ ) })) +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => mocks.state.allWorktrees() +})) + vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
    {children}
    : null, diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 598f5bedd65..b89f3fef3fa 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { useAppStore } from '@/store' +import { useAllWorktrees } from '@/store/selectors' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitStatus } from '@/runtime/runtime-git-client' @@ -36,7 +37,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const closeModal = useAppStore((s) => s.closeModal) const removeWorktree = useAppStore((s) => s.removeWorktree) const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) - const allWorktrees = useAppStore((s) => s.allWorktrees) + const allWorktrees = useAllWorktrees() const repos = useAppStore((s) => s.repos) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) const updateSettings = useAppStore((s) => s.updateSettings) @@ -62,7 +63,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { ? (modalData.onDeleted as (worktreeIds: string[]) => void) : null const worktree = useMemo( - () => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null), + () => (worktreeId ? (allWorktrees.find((item) => item.id === worktreeId) ?? null) : null), [allWorktrees, worktreeId] ) const worktrees = useMemo(() => { @@ -70,7 +71,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { return [] } const selected = new Set(worktreeIds) - return allWorktrees().filter((item) => selected.has(item.id)) + return allWorktrees.filter((item) => selected.has(item.id)) }, [allWorktrees, worktreeIds]) const repoMap = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) const isBatchDelete = worktreeIds.length > 1 @@ -90,7 +91,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const lineageDelete = useMemo( () => !isBatchDelete && worktree - ? getWorkspaceDeleteLineage(worktree, allWorktrees(), worktreeLineageById) + ? getWorkspaceDeleteLineage(worktree, allWorktrees, worktreeLineageById) : { descendants: [], deleteAllTargets: [] }, [allWorktrees, isBatchDelete, worktree, worktreeLineageById] ) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index 69771ef31f1..a188f16c83b 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -53,6 +53,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/store/selectors', () => ({ + getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()), getWorktreeMapFromState: () => mocks.state.worktreeMap })) @@ -141,6 +142,16 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) }) + it('treats duplicate selected ids as one delete target', () => { + setWorktrees([{ id: 'wt-1' }]) + + const started = runWorktreeBatchDelete(['wt-1', 'wt-1']) + + expect(started).toBe(true) + expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledTimes(1) + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) + }) + it('keeps batch deletes behind confirmation when confirmation is skipped', () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } setWorktrees([ diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index f5a653a42b9..b2dc1dbc1a6 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -1,6 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' -import { getWorktreeMapFromState } from '@/store/selectors' +import { getAllWorktreesFromState, getWorktreeMapFromState } from '@/store/selectors' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' @@ -46,16 +46,18 @@ export async function runWorktreeDeletesInParallel( targets: readonly Pick[], options: WorktreeDeleteWithToastOptions = {} ): Promise { + // Why: refresh races can leave duplicate rows, but a destructive command must run once per identity. + const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values()) // Why: capture the viewed workspace before any delete so we can focus one survivor after the batch settles, not per delete. const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId const commitBatchFocus = activeWorktreeIdBefore ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore) : null // Why: mark every target deleting up front for immediate in-flight feedback, even though deletes serialize per repo. - useAppStore.getState().markWorktreesDeleting(targets.map((target) => target.id)) + useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id)) // Why: worktree remove/prune/branch -D race on shared ref locks; group by repoId to serialize per repo (cross-repo stays parallel). - const groups = new Map() - for (const target of targets) { + const groups = new Map() + for (const target of uniqueTargets) { const group = groups.get(target.repoId) if (group) { group.push(target) @@ -95,7 +97,7 @@ export async function runWorktreeDeletesInParallel( if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { commitBatchFocus?.() } - return targets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) + return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) } /** @@ -249,8 +251,8 @@ export function runWorktreeDelete(worktreeId: string): void { } const hasLineageChildren = - getWorkspaceDeleteLineage(target, state.allWorktrees(), state.worktreeLineageById).descendants - .length > 0 + getWorkspaceDeleteLineage(target, getAllWorktreesFromState(state), state.worktreeLineageById) + .descendants.length > 0 const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false if (skipConfirm && !hasLineageChildren) { void runWorktreeDeleteWithToast(worktreeId, target.displayName) @@ -268,7 +270,7 @@ export function runWorktreeBatchDelete( ): boolean { const state = useAppStore.getState() const worktreeMap = getWorktreeMapFromState(state) - const targets = worktreeIds + const targets = Array.from(new Set(worktreeIds)) .map((id) => worktreeMap.get(id) ?? null) .filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree) @@ -295,8 +297,11 @@ export function runWorktreeBatchDelete( // Why: bulk cleanup can destroy many directories at once, so batch/Space deletes keep an explicit confirmation step. const singleTargetHasLineageChildren = targets.length === 1 && - getWorkspaceDeleteLineage(targets[0], state.allWorktrees(), state.worktreeLineageById) - .descendants.length > 0 + getWorkspaceDeleteLineage( + targets[0], + getAllWorktreesFromState(state), + state.worktreeLineageById + ).descendants.length > 0 const skipConfirm = !options.forceConfirm && targets.length === 1 && diff --git a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts index e20214d6633..817a02f2040 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-parallel-flow.test.ts @@ -31,6 +31,7 @@ vi.mock('@/store', () => ({ })) vi.mock('@/store/selectors', () => ({ + getAllWorktreesFromState: () => Array.from(mocks.state.worktreeMap.values()), getWorktreeMapFromState: () => mocks.state.worktreeMap })) @@ -145,6 +146,25 @@ describe('runWorktreeDeletesInParallel', () => { expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(2, 'wt-2', true) }) + it('deletes a duplicated target identity only once', async () => { + const target = { + id: 'wt-1', + displayName: 'one', + repoId: 'repo-a', + path: '/workspaces/one' + } + mocks.state.removeWorktree + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: false, error: 'selector_not_found' }) + + await expect(runWorktreeDeletesInParallel([target, target])).resolves.toEqual(['wt-1']) + + expect(mocks.state.markWorktreesDeleting).toHaveBeenCalledWith(['wt-1']) + expect(mocks.state.removeWorktree).toHaveBeenCalledTimes(1) + expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) + expect(toast.error).not.toHaveBeenCalled() + }) + it('clears a pending ancestor when a nested descendant delete fails', async () => { mocks.state.removeWorktree.mockImplementationOnce(async (worktreeId: string) => { mocks.state.deleteStateByWorktreeId[worktreeId] = { From 6ae19be723f5e647eb1cf0a227318312b677e846 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 20:58:11 -0700 Subject: [PATCH 040/123] [P0] fix(terminal): pause hidden paired output (#11665) --- config/reliability-gates.jsonc | 74 +++++-- src/main/runtime/orca-runtime.test.ts | 12 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/runtime/rpc/methods/terminal.ts | 45 +++- .../runtime/rpc/terminal-multiplex.test.ts | 166 +++++++++++++++ .../terminal-pane/pty-connection.test.ts | 133 ++++++++++++ .../terminal-pane/pty-connection.ts | 195 +++++++++++------- .../terminal-pane/pty-transport-types.ts | 3 + .../remote-runtime-pty-transport.test.ts | 77 ++++++- .../remote-runtime-pty-transport.ts | 23 +++ ...terminal-side-effect-facts-handler.test.ts | 13 +- .../terminal-side-effect-facts-handler.ts | 5 + .../remote-runtime-terminal-multiplexer.ts | 36 +++- ...untime-terminal-parse-backpressure.test.ts | 72 +++++++ .../runtime/runtime-terminal-stream.test.ts | 3 +- src/shared/terminal-side-effect-facts.ts | 4 +- src/shared/terminal-stream-protocol.ts | 7 +- .../paired-terminal-hidden-output-oracle.ts | 113 ++++++++++ .../paired-terminal-parking-fixture.ts | 7 + .../helpers/paired-terminal-parking-oracle.ts | 32 +-- 20 files changed, 899 insertions(+), 128 deletions(-) create mode 100644 tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ca094dce75d..312f89c7882 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3088,7 +3088,7 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" @@ -3162,12 +3162,16 @@ "file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "assertions": [ "ordinary-parks paired terminals and restores authoritative host scrollback", + "warm-mounted hidden paired streams schedule zero renderer output work under sustained host output", "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, { "file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", - "assertions": ["ordinary-parks paired terminals against an isolated headless Orca host"] + "assertions": [ + "ordinary-parks paired terminals against an isolated headless Orca host", + "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" + ] }, { "file": "tests/e2e/terminal-parked-memory.spec.ts", @@ -3335,6 +3339,24 @@ "result": "passed", "durationSeconds": 3.16, "summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17.5, + "summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 14.0, + "summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle." } ], "runtimeBudget": { @@ -3351,14 +3373,14 @@ }, "performanceBudget": { "required": true, - "evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", "Run the paired topology on Windows and Linux and add one live SSH retention run.", "Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.", "Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.", - "Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs." + "Keep the budget-off control, exact manager count, final bounded-tail flood marker, parked-time output, and post-reveal PTY input in both topology runs." ], "knownGaps": [ "Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.", @@ -6449,18 +6471,19 @@ "platforms": ["macos", "linux", "windows", "mobile"], "providers": ["remote-runtime", "ssh", "local", "daemon"], "coveredPlatforms": ["macos"], - "coveredProviders": [], - "coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.", + "coveredProviders": ["remote-runtime"], + "coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6951", "https://github.com/stablyai/orca/pull/6955", "https://github.com/stablyai/orca/pull/7009" ], - "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.", - "oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", + "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. A capability-negotiated hidden paired desktop stream must deliver zero raw output frames after host model ingestion, preserve side-effect facts, and restore from one authoritative snapshot before exact live output resumes. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and safe mixed-version fallback.", + "oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Pause three negotiated paired desktop streams, sustain output, and assert zero renderer frames; reveal one and assert one authoritative snapshot followed by exact live bytes with no loss or duplication. Prove old clients continue receiving output and new clients never send pause to old hosts. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1", "ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts" @@ -6471,6 +6494,7 @@ "src/main/runtime/rpc/terminal-multiplex.test.ts", "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts", "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts", "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", @@ -6502,7 +6526,24 @@ "multibyte live output flushes when encoded bytes reach the batch budget", "adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin", "send and recovery serialization failures detach once instead of leaking credit or retrying forever", - "32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter" + "32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter", + "three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live", + "an older client that omits pause negotiation continues receiving output" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable", + "reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication", + "dispose releases pause and unregisters the fact consumer" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts", + "assertions": [ + "desired pause reapplies after either capability/snapshot ordering and across reconnect", + "reconnect delivers each authoritative snapshot and post-resume live marker exactly once" ] }, { @@ -6577,6 +6618,15 @@ "result": "passed", "durationSeconds": 0.91, "summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 9.05, + "summary": "All 58 terminal multiplex tests passed, including the three-stream hidden-output oracle. Its byte-identical title-selected command failed on current origin/main and failed again with the two causal host/protocol files reverted because hidden Output frames reached the renderer." } ], "runtimeBudget": { @@ -6589,11 +6639,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests cover mobile initial snapshot byte downgrade, requested binary snapshot byte downgrade, pending live-output cap while snapshot loads, output chunk size, output coalescing, abort cleanup, and stale resize re-stream suppression. Needs intentional-break proof plus JSON fallback coverage before promotion." + "evidence": "The hidden paired-output oracle is red on origin/main, green on the candidate, and red with its causal host/protocol files reverted. It proves three paused streams emit no raw frames while an unpaused control remains live; compatibility tests preserve old-client/new-host output and prevent new-client/old-host pause opcodes. The broader gate remains partial pending legacy JSON fallback and live Android coverage." }, "performanceBudget": { "required": true, - "evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers." + "evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). Negotiated hidden paired streams add zero renderer output frames or scheduler drains after host ingestion; headed and headless six-stream runs enforce under 500 ms timer lag. The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB." }, "promotionCriteria": [ "Gate binary multiplex first.", diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3855ad4e37b..6c137a4dcf0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8579,17 +8579,23 @@ describe('OrcaRuntimeService', () => { expect(batches[0].seq).toBeLessThan(batches[1].seq) }) - it('emits nothing for chunks without derived facts', () => { + it('emits only agent-status facts for status-only chunks', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) - // Plain output, a BEL-terminated non-title OSC split across chunks, and an Orca status payload — none is a title/bell/agent fact. + // Plain output and a BEL-terminated non-title OSC stay fact-free. runtime.onPtyData('pty-1', 'plain output\r\n', 100) runtime.onPtyData('pty-1', '\x1b]7;file://host', 101) runtime.onPtyData('pty-1', '/tmp\x07', 102) runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) - expect(batches).toEqual([]) + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { + kind: 'agent-status', + payload: expect.objectContaining({ state: 'working', agentType: 'codex' }) + } + ]) }) it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 57d80f75a59..d6bc500632f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1429,8 +1429,8 @@ type RuntimePtyTitleTrackerEntry = { lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one - // pty:sideEffect emission per chunk, preserving byte order (titles in - // sequence, then bell). Timer-fired facts emit immediately between chunks. + // pty:sideEffect emission per chunk, preserving status/title/bell order. + // Timer-fired facts emit immediately between chunks. pendingFacts: TerminalSideEffectFact[] // Why: Command Code lacks hooks, so its working/done state is scraped from // TUI output. Null when no side-effect consumer exists (headless serve) — @@ -9198,6 +9198,9 @@ export class OrcaRuntimeService { titleTrackerEntry.chunkTouchedSessionTabs = false let retainedAgentStatusChanged = false try { + for (const payload of agentStatusChunk.payloads) { + titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) + } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { titleScanData: titleInput }) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 4ca479ea5cc..450437f58be 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -130,6 +130,8 @@ type TerminalMultiplexStream = { sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null ackInFlightBytes: number ackWindowBytes: number + supportsOutputPause: boolean + outputPaused: boolean supportsDesktopViewportClaims: boolean desktopClaimTail: Promise // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. @@ -1027,7 +1029,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ .object({ ackOutput: z.literal(1).optional(), ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional() + desktopViewportClaims: z.literal(1).optional(), + outputPause: z.literal(1).optional() }) .optional() }) @@ -1721,7 +1724,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk ): void => { - if (closed || streams.get(stream.streamId) !== stream) { + if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return } if ( @@ -1738,6 +1741,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if ( closed || streams.get(stream.streamId) !== stream || + stream.outputPaused || stream.ackRecoverySnapshotInFlight ) { return @@ -1746,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null try { const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream) { + if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { return } if (!serialized) { @@ -1862,6 +1866,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ stream: TerminalMultiplexStream, maxChunks = Number.POSITIVE_INFINITY ): number => { + if (stream.outputPaused) { + return 0 + } if (stream.ackPendingOutputOverflowed) { void sendAckRecoverySnapshot(stream) return 0 @@ -2112,6 +2119,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) return } + if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { + const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) + if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { + return + } + stream.outputPaused = payload.paused + if (stream.outputPaused) { + stream.outputBatcher.flush() + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + } + return + } if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( frame.payload @@ -2430,6 +2451,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ sourceRangeReplacement: null, ackInFlightBytes: 0, ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, + supportsOutputPause: request.capabilities?.outputPause === 1, + outputPaused: false, supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, desktopClaimTail: Promise.resolve(true), registeredRemoteDesktopDriver: false, @@ -2476,6 +2499,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (closed || streams.get(request.streamId) !== stream) { return } + if (stream.outputPaused) { + return + } if (stream.buffering) { appendPendingMultiplexOutput(stream, data, meta) return @@ -2554,12 +2580,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ rows: serialized?.rows ?? size?.rows, displayMode, seq: layoutSeq, - ...(stream.ackOutputSourceRanges - ? { - streamGeneration: stream.streamGeneration, - capabilities: { ackOutputSourceRanges: 1 as const } - } - : {}), + ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + capabilities: { + ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + } + }), + ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. truncated: initialOutputOverflowed }) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 17e31eb58aa..b1584f0c604 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -21,6 +21,8 @@ import { } from '../../../shared/terminal-multiplex-flow-control' import { SshPtyOutputIntake, type SshPtyOutputDataEvent } from '../../ipc/ssh-pty-output-intake' +const SET_OUTPUT_PAUSED_OPCODE = 16 as TerminalStreamOpcode + function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { const serializeAuthoritativeTerminalBuffer = overrides.serializeAuthoritativeTerminalBuffer ?? @@ -1318,6 +1320,170 @@ describe('terminal multiplex RPC', () => { } }) + it('withholds sustained output from multiple paused desktop streams', async () => { + const listeners: ((data: string, meta?: RuntimeTerminalDataMeta) => void)[] = [] + const harness = startDesktopMultiplexSubscribe({ + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + listeners.push(listener) + return vi.fn() + }), + serializeAuthoritativeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'authoritative hidden snapshot', + cols: 120, + rows: 40, + seq: 7, + source: 'headless' + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + for (const streamId of [1, 2, 3]) { + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + capabilities: { ackOutput: 1, outputPause: 1 } + }) + }) + )! + ) + } + await vi.waitFor(() => + expect( + harness.messages.filter((message) => JSON.parse(message).result?.type === 'subscribed') + ).toHaveLength(3) + ) + const pauseCapable = harness.messages + .map((message) => JSON.parse(message).result) + .filter((event) => event?.type === 'subscribed') + .every((event) => event.capabilities?.outputPause === 1) + if (pauseCapable) { + for (const streamId of [1, 2, 3]) { + harness.handlers.get(streamId)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId, + seq: 10, + payload: encodeTerminalStreamJson({ paused: true }) + }) + )! + ) + } + } + harness.binaryFrames.splice(0) + const chunk = 'x'.repeat(64 * 1024) + for (let turn = 0; turn < 8; turn += 1) { + for (const listener of listeners) { + listener(chunk, { seq: (turn + 1) * chunk.length, rawLength: chunk.length }) + } + } + expect( + harness.binaryFrames.some( + (bytes) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Output + ) + ).toBe(false) + expect(pauseCapable).toBe(true) + + harness.handlers.get(1)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId: 1, + seq: 11, + payload: encodeTerminalStreamJson({ paused: false }) + }) + )! + ) + listeners[0]?.('VISIBLE_MARKER', { seq: 8 * chunk.length + 14, rawLength: 14 }) + listeners[0]?.('y'.repeat(64 * 1024), { + seq: 9 * chunk.length + 14, + rawLength: 64 * 1024 + }) + expect( + harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('VISIBLE_MARKER') + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + expect(harness.handlers.size).toBe(0) + }) + + it('keeps output flowing when an older client does not negotiate pause', async () => { + const listeners: ((data: string, meta?: RuntimeTerminalDataMeta) => void)[] = [] + const harness = startDesktopMultiplexSubscribe({ + subscribeToTerminalData: vi.fn((_ptyId, nextListener) => { + listeners.push(nextListener) + return vi.fn() + }) + }) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + + harness.handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 1, + terminal: 'terminal-legacy-client', + client: { id: 'desktop-legacy', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect( + harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed' && event.streamId === 1) + ).toMatchObject({ type: 'subscribed', streamId: 1 }) + ) + const subscribed = harness.messages + .map((message) => JSON.parse(message).result) + .find((event) => event?.type === 'subscribed' && event.streamId === 1) + expect(subscribed.capabilities?.outputPause).toBeUndefined() + + harness.handlers.get(1)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: SET_OUTPUT_PAUSED_OPCODE, + streamId: 1, + seq: 2, + payload: encodeTerminalStreamJson({ paused: true }) + }) + )! + ) + harness.binaryFrames.splice(0) + listeners[0]?.('LEGACY_VISIBLE'.padEnd(64 * 1024, 'x'), { + seq: 64 * 1024, + rawLength: 64 * 1024 + }) + + expect( + harness.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame!.payload)) + .join('') + ).toContain('LEGACY_VISIBLE') + + harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.() + await harness.dispatchPromise + }) + it('applies a viewer resize parked during a snapshot-request buffering window', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1d42057d539..775251943ce 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -305,6 +305,7 @@ type ConnectCallbacks = { onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void onWriteUnavailable?: () => void + onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } type MockTransport = { @@ -320,6 +321,7 @@ type MockTransport = { sendInputImmediate: ReturnType sendInputAccepted?: ReturnType claimViewport: ReturnType + setOutputPaused?: ReturnType resize: ReturnType getPtyId: ReturnType getConnectionId: ReturnType @@ -14004,6 +14006,137 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('pauses capable paired output while hidden and restores exactly on reveal', async () => { + const { connectPanePty } = await import('./pty-connection') + const remotePtyId = 'remote:env-1@@terminal-1' + const transport = createMockTransport(remotePtyId) + let callbacks: ConnectCallbacks = {} + transport.connect.mockImplementation(async (options: { callbacks?: ConnectCallbacks }) => { + callbacks = options.callbacks ?? {} + return remotePtyId + }) + transport.setOutputPaused = vi.fn((paused: boolean) => { + callbacks.onOutputPauseChanged?.(paused, true) + return true + }) + const hidden = 'paired hidden flood\r\n' + const snapshot = 'paired snapshot with hidden flood\r\n' + const live = 'paired visible marker\r\n' + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: snapshot, + cols: 100, + rows: 30, + seq: hidden.length, + source: 'headless' + }) + transportFactoryQueue.push(transport) + mockStoreState.repos = [ + { id: 'repo1', connectionId: null, displayName: 'orca', executionHostId: 'runtime:env-1' } + ] + mockStoreState.worktreesByRepo.repo1[0].runtimeOwnerEnvironmentId = 'env-1' + + const pane = createPane(1) + const manager = createManager(1) + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(true) + expect( + (transport.sendInput as unknown as (data: string) => boolean)('hidden input marker') + ).toBe(true) + expect(transport.sendInput).toHaveBeenCalledWith('hidden input marker') + callbacks.onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + + const factsHandler = await import('./terminal-side-effect-facts-handler') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + mockStoreState.ptyIdsByTabId = { 'tab-1': [remotePtyId] } + mockStoreState.terminalLayoutsByTabId!['tab-1'].ptyIdsByLeafId = { + [LEAF_1]: remotePtyId + } + const visibleStatusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { state: 'working'; prompt: string; agentType: 'claude' }) => void) + | undefined + expect(visibleStatusHandler).toBeTypeOf('function') + visibleStatusHandler?.({ + state: 'working', + prompt: 'visible control', + agentType: 'claude' + }) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + mockStoreState.setAgentStatus.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length, + facts: [ + { + kind: 'agent-status', + payload: { state: 'working', prompt: 'paired task', agentType: 'claude' } + }, + { kind: 'title', normalizedTitle: 'remote working', rawTitle: 'remote working' }, + { kind: '2031-subscribe' } + ] + }) + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'remote working') + expect(mockStoreState.setAgentStatus).toHaveBeenCalledTimes(1) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + makePaneKey('tab-1', LEAF_1), + { state: 'working', prompt: 'paired task', agentType: 'claude' }, + undefined, + undefined, + { connectionId: null } + ) + expect(transport.sendInput).toHaveBeenCalledWith(expect.stringContaining('\x1b[')) + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, expect.any(String)) + deps.paneMode2031Ref.current.set(1, true) + deps.paneLastThemeModeRef.current.set(1, 'dark') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + 1, + facts: [{ kind: '2031-unsubscribe' }] + }) + expect(deps.paneMode2031Ref.current.has(1)).toBe(false) + + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + await flushAsyncTicks(20) + + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(transport.setOutputPaused.mock.invocationCallOrder.at(-1)).toBeLessThan( + transport.serializeBuffer.mock.invocationCallOrder[0] + ) + callbacks.onData?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + const countWritten = (marker: string): number => written.split(marker).length - 1 + expect(countWritten(snapshot)).toBe(1) + expect(countWritten(live)).toBe(1) + expect(countWritten(hidden)).toBe(0) + + binding.dispose() + expect(transport.setOutputPaused).toHaveBeenLastCalledWith(false) + deps.setRuntimePaneTitle.mockClear() + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: remotePtyId, + seq: hidden.length + live.length + 1, + facts: [{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }] + }) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + }) + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index daf09427ca7..49c11f55cb3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -36,7 +36,7 @@ import { import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' -import type { PtyTransportRecoveryState } from './pty-transport-types' +import type { IpcPtyTransportOptions, PtyTransportRecoveryState } from './pty-transport-types' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation' @@ -1098,6 +1098,11 @@ export function connectPanePty( // flips, exit, dispose) run before/after it exists, so start with no-ops. let syncHiddenRendererPtyDelivery: () => void = () => {} let releaseHiddenRendererPtyDelivery: () => void = () => {} + let handleRemoteOutputPauseChanged: (paused: boolean, supported: boolean) => void = () => {} + let handleRendererOwnedAgentStatus: NonNullable< + IpcPtyTransportOptions['onAgentStatus'] + > = () => {} + let remoteOutputPausedPtyId: string | null = null let suppressViewportClaimTerminalResize = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer @@ -2285,8 +2290,11 @@ export function connectPanePty( // restoreTitleOnRegister replaces the eager-replay title restore: main's // title-only snapshot carries the no-attention-replay rule. let unregisterSideEffectFactConsumer: (() => void) | null = null - const registerSideEffectFactConsumerForPty = (ptyId: string): void => { - if (!mainSideEffectAuthority || disposed) { + const registerSideEffectFactConsumerForPty = ( + ptyId: string, + remoteOutputPaused = false + ): void => { + if ((!mainSideEffectAuthority && !remoteOutputPaused) || disposed) { return } unregisterSideEffectFactConsumer?.() @@ -2306,9 +2314,12 @@ export function connectPanePty( // renderer seeds also write), so main only emits scrape facts. onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + ...(shouldOwnAgentStatusInRenderer + ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } + : {}), // Why: gated hidden panes never see the subscribe bytes; the fact // replaces the byte scan (and the old post-latch subscribe drop). - ...(hiddenDeliveryGateActive + ...(hiddenDeliveryGateActive || remoteOutputPaused ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact, onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact @@ -3553,6 +3564,53 @@ export function connectPanePty( }) : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + handleRendererOwnedAgentStatus = (payload): void => { + if ( + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } + const currentState = useAppStore.getState() + const routing = resolveCurrentAgentStatusRouting() + if (!routing) { + return + } + const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const authoritativePaneAgent = getAuthoritativePaneAgent() + const agentType = resolveCompatibleAgentTypeForOwner(payload.agentType, authoritativePaneAgent) + const statusPayload = agentType === payload.agentType ? payload : { ...payload, agentType } + const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) + const statusTitle = resolvedStatusTitle + ? normalizeCompatibleAgentTitleForOwner( + resolvedStatusTitle, + agentType ?? authoritativePaneAgent + ) + : resolvedStatusTitle + if (launchToken) { + currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing, { + launchToken + }) + } else { + currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing) + } + if (payload.state === 'working' && syncAgentTaskCompleteTrackingEnabled()) { + requiresFreshWorkingForAgentTaskCompleteNotification = false + } + const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] + const notificationPayload = + typeof storedStatus?.stateStartedAt === 'number' + ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } + : statusPayload + // Why: hook lifecycle owns deferred side effects even when alerts are disabled. + agentCompletionCoordinator.observeHookStatus(notificationPayload) + if (payload.state === 'working' && pendingTerminalBellNotification) { + scheduleTerminalBellNotification() + } + } // Why: when main holds side-effect authority for this PTY's bytes, the // transport must NOT register title/bell/agent byte parsers — the // pty:sideEffect fact consumer below is the single policy consumer. @@ -3699,75 +3757,7 @@ export function connectPanePty( // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through // local main, so the renderer remains their status owner for now. - ...(shouldOwnAgentStatusInRenderer - ? { - onAgentStatus: (payload) => { - if ( - shouldSuppressCodexAutoApprovalStatus(payload, { - paneKey: cacheKey, - tabId: deps.tabId, - ...(launchToken ? { launchToken } : {}) - }) - ) { - return - } - // Why: capture the store snapshot once so the title lookup and the - // setAgentStatus call observe the same state. Re-reading getState() - // between the two lines opens a brief window where the title could - // shift (OSC title update landing in between) and the status would - // be stored against a title that was never paired with it. - const currentState = useAppStore.getState() - const routing = resolveCurrentAgentStatusRouting() - if (!routing) { - return - } - const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] - const authoritativePaneAgent = getAuthoritativePaneAgent() - const agentType = resolveCompatibleAgentTypeForOwner( - payload.agentType, - authoritativePaneAgent - ) - const statusPayload = - agentType === payload.agentType ? payload : { ...payload, agentType } - const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title) - const statusTitle = resolvedStatusTitle - ? normalizeCompatibleAgentTitleForOwner( - resolvedStatusTitle, - agentType ?? authoritativePaneAgent - ) - : resolvedStatusTitle - if (launchToken) { - currentState.setAgentStatus( - cacheKey, - statusPayload, - statusTitle, - undefined, - routing, - { - launchToken - } - ) - } else { - currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing) - } - const trackingEnabled = syncAgentTaskCompleteTrackingEnabled() - if (payload.state === 'working' && trackingEnabled) { - requiresFreshWorkingForAgentTaskCompleteNotification = false - } - const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey] - const notificationPayload = - typeof storedStatus?.stateStartedAt === 'number' - ? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt } - : statusPayload - // Why: hook lifecycle owns deferred terminal side effects even when - // every outward completion alert consumer is disabled. - agentCompletionCoordinator.observeHookStatus(notificationPayload) - if (payload.state === 'working' && pendingTerminalBellNotification) { - scheduleTerminalBellNotification() - } - } - } - : {}) + ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: handleRendererOwnedAgentStatus } : {}) } if (connectionOwnerHydrating) { // Why: this pane holds an inert transport until its host resolves; register it so @@ -3800,7 +3790,8 @@ export function connectPanePty( // skipped-byte scan are disabled for these panes (same structural // predicate), so exactly one reply goes out. const handleHiddenMode2031SubscribeFact = (): void => { - if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + const ptyId = transport.getPtyId() + if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputPausedPtyId !== ptyId)) { return } const mode = resolveTerminalColorSchemeMode( @@ -3824,7 +3815,8 @@ export function connectPanePty( // into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is // sent: a withdrawal is not a query. const handleHiddenMode2031UnsubscribeFact = (): void => { - if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + const ptyId = transport.getPtyId() + if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputPausedPtyId !== ptyId)) { return } deps.paneMode2031Ref.current.delete(pane.id) @@ -5720,6 +5712,11 @@ export function connectPanePty( pane.container.dataset.ptyRecoveryState = state.phase deps.onPtyRecoveryStateRef?.current?.(pane.id, state) } + }, + onOutputPauseChanged: (paused: boolean, supported: boolean): void => { + if (isCurrent()) { + handleRemoteOutputPauseChanged(paused, supported) + } } } } @@ -5975,9 +5972,45 @@ export function connectPanePty( ) } + handleRemoteOutputPauseChanged = (paused, supported): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + return + } + if (!supported || !paused) { + if (remoteOutputPausedPtyId === ptyId) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } + if (supported && !paused && hiddenOutputRestorePtyId === ptyId) { + requestHiddenOutputRestoreIfNeeded() + } + return + } + if (remoteOutputPausedPtyId !== ptyId) { + remoteOutputPausedPtyId = ptyId + registerSideEffectFactConsumerForPty(ptyId, true) + } + markHiddenOutputRestoreNeeded() + } + syncHiddenRendererPtyDelivery = (): void => { const ptyId = transport.getPtyId() syncModelRestoreNeededSubscription(ptyId) + if (remoteOutputPausedPtyId !== null && remoteOutputPausedPtyId !== ptyId) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } + if (isRemoteRuntimePtyId(ptyId) && canUseHiddenOutputSnapshot(ptyId)) { + transport.setOutputPaused?.( + !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + return + } if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { releaseHiddenDeliveryClaim?.() releaseHiddenDeliveryClaim = null @@ -6002,6 +6035,13 @@ export function connectPanePty( } } releaseHiddenRendererPtyDelivery = (): void => { + transport.setOutputPaused?.(false) + if (remoteOutputPausedPtyId !== null) { + remoteOutputPausedPtyId = null + if (!mainSideEffectAuthority) { + dropSideEffectFactConsumer() + } + } releaseHiddenDeliveryClaim?.() releaseHiddenDeliveryClaim = null hiddenDeliverySyncedPtyId = null @@ -6292,10 +6332,11 @@ export function connectPanePty( } function shouldSkipHiddenRendererOutput(foreground: boolean, data: string): boolean { + const ptyId = transport.getPtyId() if ( foreground || - !shouldSnapshotHiddenCodexOutput || - !canUseHiddenOutputSnapshot(transport.getPtyId()) + (!shouldSnapshotHiddenCodexOutput && remoteOutputPausedPtyId !== ptyId) || + !canUseHiddenOutputSnapshot(ptyId) ) { return false } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index ef7d9974464..f56949a2c16 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -84,6 +84,7 @@ type PtyCallbacks = { onExit?: (code: number) => void onWriteUnavailable?: () => void onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void + onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } export type PtyTransportRecoveryState = { @@ -141,6 +142,8 @@ export type PtyTransport = { sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise claimViewport?: (cols: number, rows: number) => boolean + /** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */ + setOutputPaused?: (paused: boolean) => boolean resize: ( cols: number, rows: number, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 8e6f8690267..9384890b4ea 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -226,7 +226,8 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestSubscribePayload().capabilities).toEqual({ ackOutput: 1, ackOutputSourceRanges: 1, - desktopViewportClaims: 1 + desktopViewportClaims: 1, + outputPause: 1 }) ) expect(runtimeSubscribe).toHaveBeenCalledWith( @@ -3523,6 +3524,80 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) }) + it('reapplies negotiated output pause across reconnect and resumes exact snapshot plus live data', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onData = vi.fn() + const onOutputPauseChanged = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + + await transport.connect({ + url: '', + callbacks: { onData, onReplayData, onOutputPauseChanged } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(transport.setOutputPaused?.(true)).toBe(false) + const firstStreamId = latestSubscribePayload().streamId + emitSnapshot(firstStreamId, 'INITIAL_SNAPSHOT') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: firstStreamId, + capabilities: { outputPause: 1 } + } + }) + await vi.waitFor(() => + expect( + decodeTerminalStreamJson<{ paused?: boolean }>( + latestFrameForOpcode(TerminalStreamOpcode.SetOutputPaused)!.payload + ) + ).toEqual({ paused: true }) + ) + + subscriptionCallbacks?.onClose?.() + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => + expect( + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + ).toHaveLength(2) + ) + const reconnectStreamId = latestSubscribePayload().streamId + emitSnapshot(reconnectStreamId, 'RECONNECT_SNAPSHOT') + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: reconnectStreamId, + capabilities: { outputPause: 1 } + } + }) + await vi.waitFor(() => + expect( + subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SetOutputPaused) + .map((frame) => decodeTerminalStreamJson<{ paused?: boolean }>(frame!.payload)) + ).toEqual([{ paused: true }, { paused: true }]) + ) + + expect(transport.setOutputPaused?.(false)).toBe(true) + emitOutput(reconnectStreamId, 'LIVE_AFTER_RECONNECT') + expect(onReplayData.mock.calls.map((call) => call[0])).toEqual([ + 'INITIAL_SNAPSHOT', + 'RECONNECT_SNAPSHOT' + ]) + expect(onData.mock.calls.map((call) => call[0])).toEqual(['LIVE_AFTER_RECONNECT']) + expect(onOutputPauseChanged).toHaveBeenLastCalledWith(false, true) + transport.destroy?.() + }) + it('backs off before retrying a capacity-rejected terminal stream', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index e7df8f18257..615761567ef 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -157,6 +157,7 @@ export function createRemoteRuntimePtyTransport( const runtimeEnvironmentPairingRevision = getRuntimeEnvironmentRevision(runtimeEnvironmentId) let multiplexedStream: RemoteRuntimeMultiplexedTerminal | null = null let multiplexedStreamHandle: string | null = null + let desiredOutputPaused = false let desiredViewport: { cols: number; rows: number } | null = null let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribeEpoch: number | null = null @@ -1478,10 +1479,22 @@ export function createRemoteRuntimePtyTransport( }) } }, + onOutputPauseCapability: () => { + if (isCurrentSubscription()) { + storedCallbacks.onOutputPauseChanged?.( + desiredOutputPaused, + nextStream.setOutputPaused(desiredOutputPaused) + ) + } + }, onSubscribed: () => { if (!isCurrentSubscription()) { return } + storedCallbacks.onOutputPauseChanged?.( + desiredOutputPaused, + nextStream.setOutputPaused(desiredOutputPaused) + ) subscriptionAttached = true setAttachmentReady(true) connecting = false @@ -2040,6 +2053,16 @@ export function createRemoteRuntimePtyTransport( return true }, + setOutputPaused(paused: boolean): boolean { + desiredOutputPaused = paused + if (!connected || !handle) { + return false + } + const supported = getCurrentMultiplexedStream(handle)?.setOutputPaused(paused) === true + storedCallbacks.onOutputPauseChanged?.(paused, supported) + return supported + }, + resize(cols: number, rows: number, meta): boolean { if (!connected || !handle) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index b73f690e5f4..e05137ffb64 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -152,10 +152,20 @@ describe('registerTerminalSideEffectFactConsumer', () => { it('routes live facts to the registered consumer in batch order', () => { const { callbacks, events } = createCallbackRecorder() - registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + ...callbacks, + onAgentStatus: (payload) => events.push(['status', payload.state, payload.agentType]) + } + }) _dispatchTerminalSideEffectBatchForTest( batch([ + { + kind: 'agent-status', + payload: { state: 'working', prompt: 'fix it', agentType: 'codex' } + }, { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, { kind: 'agent-working' }, { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, @@ -165,6 +175,7 @@ describe('registerTerminalSideEffectFactConsumer', () => { ) expect(events).toEqual([ + ['status', 'working', 'codex'], ['title', '⠋ Claude', '⠋ Claude'], ['working'], ['title', '✳ Claude', '✳ Claude'], diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index e7f4a54ecd3..546af83987b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -13,6 +13,7 @@ * is a PTY whose consumer just unregistered: see the handoff buffer below. */ import type { GlobalSettings } from '../../../../shared/types' +import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { TerminalSideEffectBatch, @@ -63,6 +64,7 @@ export function isMainTerminalSideEffectAuthorityForPty(args: { } export type TerminalSideEffectFactConsumerCallbacks = { + onAgentStatus?: (payload: ParsedAgentStatusPayload) => void /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s * stale-title timer — policy must clear title/cache state without * scheduling task-complete notifications or unread attention. */ @@ -104,6 +106,9 @@ let channelUnsubscribe: (() => void) | null = null function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { switch (fact.kind) { + case 'agent-status': + entry.callbacks.onAgentStatus?.(fact.payload) + return case 'title': entry.lastLiveTitleSeq = seq entry.callbacks.onTitleChange?.( diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 3b2761504bf..5ea3510c2e0 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -36,7 +36,7 @@ type TerminalMultiplexEvent = type: 'subscribed' streamId: number streamGeneration?: string - capabilities?: { ackOutputSourceRanges?: 1 } + capabilities?: { ackOutputSourceRanges?: 1; outputPause?: 1 } } | { type: 'end'; streamId: number } | { type: 'error'; streamId: number; message?: string } @@ -58,6 +58,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { onData: (data: string, meta?: { seq?: number; rawLength?: number; transformed?: boolean }) => void onSnapshot: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void onSubscribed?: () => void + onOutputPauseCapability?: () => void onEnd?: () => void onError?: (message: string) => void onFitOverrideChanged?: (event: { @@ -76,6 +77,7 @@ export type RemoteRuntimeMultiplexedTerminal = { sendInput: (text: string) => boolean resize: (cols: number, rows: number) => boolean claimViewport: (cols: number, rows: number) => boolean + setOutputPaused: (paused: boolean) => boolean serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{ data: string cols: number @@ -93,6 +95,8 @@ type RemoteRuntimeMultiplexedTerminalState = { subscriptionRequested: boolean acknowledgeOutput: boolean acknowledgeOutputSourceRanges: boolean + supportsOutputPause: boolean + outputPaused: boolean streamGeneration: string | null sourceAckedEndByte: number heldAckBytes: number @@ -320,6 +324,8 @@ class RemoteRuntimeTerminalMultiplexer { subscriptionRequested: false, acknowledgeOutput: true, acknowledgeOutputSourceRanges: false, + supportsOutputPause: false, + outputPaused: false, streamGeneration: null, sourceAckedEndByte: 0, heldAckBytes: 0, @@ -391,6 +397,7 @@ class RemoteRuntimeTerminalMultiplexer { ) return claimed && resized }, + setOutputPaused: (paused) => this.setOutputPaused(state, paused), serializeBuffer: (opts) => this.requestSnapshot(state, opts), close: () => { if (this.streams.get(streamId) === state) { @@ -421,6 +428,7 @@ class RemoteRuntimeTerminalMultiplexer { capabilities: { ackOutput: 1, ackOutputSourceRanges: 1, + outputPause: 1, ...(args.client.type === 'desktop' ? { desktopViewportClaims: 1 } : {}) } }) @@ -539,7 +547,7 @@ class RemoteRuntimeTerminalMultiplexer { if (event.type === 'subscribed') { const capabilities = typeof event.capabilities === 'object' && event.capabilities !== null - ? (event.capabilities as { ackOutputSourceRanges?: unknown }) + ? (event.capabilities as { ackOutputSourceRanges?: unknown; outputPause?: unknown }) : null if ( capabilities?.ackOutputSourceRanges === 1 && @@ -549,6 +557,10 @@ class RemoteRuntimeTerminalMultiplexer { stream.acknowledgeOutputSourceRanges = true stream.streamGeneration = event.streamGeneration } + stream.supportsOutputPause = capabilities?.outputPause === 1 + if (stream.supportsOutputPause) { + stream.callbacks.onOutputPauseCapability?.() + } } else if (event.type === 'end') { discardOutputAcknowledgements(stream) stream.watchdog.dispose() @@ -1043,12 +1055,30 @@ class RemoteRuntimeTerminalMultiplexer { TerminalStreamOpcode.Input, encodeTerminalStreamText(text) ) - if (sent) { + if (sent && !stream.outputPaused) { stream.watchdog.recordCommandInput(text) } return sent } + private setOutputPaused(stream: RemoteRuntimeMultiplexedTerminalState, paused: boolean): boolean { + if (!stream.supportsOutputPause || this.streams.get(stream.streamId) !== stream) { + return false + } + if (stream.outputPaused === paused) { + return true + } + const sent = this.sendFrame( + stream.streamId, + TerminalStreamOpcode.SetOutputPaused, + encodeTerminalStreamJson({ paused }) + ) + if (sent) { + stream.outputPaused = paused + } + return sent + } + private probeCommandResponse(stream: RemoteRuntimeMultiplexedTerminalState): void { void this.requestSnapshot(stream).then( (snapshot) => { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts index 9d4c13bcfad..ba49c7cfa26 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts @@ -157,6 +157,71 @@ describe('remote terminal renderer backpressure', () => { stream.close() }) + it('pauses output only after the host confirms the stream capability', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-hidden', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn() } + }) + sendBinary.mockClear() + + expect(stream.setOutputPaused(true)).toBe(false) + expect(sentOpcodes()).not.toContain(TerminalStreamOpcode.SetOutputPaused) + + callbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: stream.streamId, + capabilities: { outputPause: 1 } + } + }) + + expect(stream.setOutputPaused(true)).toBe(true) + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.SetOutputPaused]) + expect( + decodeTerminalStreamJson<{ paused?: boolean }>( + decodeTerminalStreamFrame(sendBinary.mock.calls[0]![0])!.payload + ) + ).toEqual({ paused: true }) + expect(stream.setOutputPaused(true)).toBe(true) + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.SetOutputPaused]) + stream.close() + }) + + it('keeps paused input immediate without arming a hidden-output probe', async () => { + vi.useFakeTimers() + try { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-hidden-input', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn() } + }) + callbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId: stream.streamId, + capabilities: { outputPause: 1 } + } + }) + expect(stream.setOutputPaused(true)).toBe(true) + sendBinary.mockClear() + + expect(stream.sendInput('\r')).toBe(true) + await vi.advanceTimersByTimeAsync(10_000) + + expect(sentOpcodes()).toEqual([TerminalStreamOpcode.Input]) + stream.close() + } finally { + vi.useRealTimers() + } + }) + it('releases unknown streams and closes malformed connections instead of leaking credit', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') @@ -372,4 +437,11 @@ describe('remote terminal renderer backpressure', () => { return typeof payload?.bytes === 'number' ? [payload.bytes] : [] }) } + + function sentOpcodes(): TerminalStreamOpcode[] { + return sendBinary.mock.calls.flatMap(([bytes]) => { + const opcode = decodeTerminalStreamFrame(bytes)?.opcode + return opcode === undefined ? [] : [opcode] + }) + } }) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 13e6278e91b..75c6a5c03db 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -112,7 +112,8 @@ describe('remote runtime terminal data subscriptions', () => { expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1, ackOutputSourceRanges: 1, - desktopViewportClaims: 1 + desktopViewportClaims: 1, + outputPause: 1 }) callbacks?.onBinary?.( diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index ce388f730fc..d9c4ce6e46a 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -5,6 +5,7 @@ * renderer store handler owns notification/unread policy. */ +import type { ParsedAgentStatusPayload } from './agent-status-types' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not @@ -12,6 +13,7 @@ import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' * must not schedule task-complete notifications or unread attention — a * merely-paused agent (>3s silent mid-task) is not a completion. */ export type TerminalSideEffectFact = + | { kind: 'agent-status'; payload: ParsedAgentStatusPayload } | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } | { kind: 'bell' } | { kind: 'agent-working' } @@ -43,7 +45,7 @@ export type TerminalSideEffectBatch = { * their title state was current at, so the handler can drop a replay title * older than the last live title fact it applied. */ seq: number - /** Facts from one chunk, in byte order: titles in sequence, then bell. + /** Facts from one chunk, in byte order: agent status, titles, then bell. * Command Code scrape facts trail the chunk's parser facts — their policy * (status-row seeding) never interacts with title/bell ordering. */ facts: TerminalSideEffectFact[] diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 76e82eafce9..5cd5f32ff96 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -28,7 +28,9 @@ export enum TerminalStreamOpcode { // Why 14: Ack already occupies 13 on current clients; older runtimes ignore // this opcode and still receive the compatibility Resize frame behind it. ClaimViewport = 14, - OutputSpan = 15 + OutputSpan = 15, + // Negotiated per stream; older hosts reject unknown opcodes, so clients send only after capability confirmation. + SetOutputPaused = 16 } export type TerminalStreamFrame = { @@ -116,6 +118,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Metadata || value === TerminalStreamOpcode.Ack || value === TerminalStreamOpcode.ClaimViewport || - value === TerminalStreamOpcode.OutputSpan + value === TerminalStreamOpcode.OutputSpan || + value === TerminalStreamOpcode.SetOutputPaused ) } diff --git a/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts b/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts new file mode 100644 index 00000000000..562947d5dff --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-hidden-output-oracle.ts @@ -0,0 +1,113 @@ +import type { Page } from '@stablyai/playwright-test' +import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types' +import { startRendererLagProbe } from '../paired-runtime-retention-metrics' +import { expect } from './orca-app' + +const MAX_HIDDEN_FLOOD_LAG_MS = 500 + +type HiddenPairedTerminal = { + tabId: string + terminal: string +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function verifyHiddenPairedTerminalOutputSuppression( + page: Page, + terminals: HiddenPairedTerminal[] +): Promise { + await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) + await expect + .poll( + () => + page.evaluate( + (ids) => ids.filter((id) => window.__paneManagers?.has(id)).length, + terminals.map((terminal) => terminal.tabId) + ), + { timeout: 10_000 } + ) + .toBe(terminals.length) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) + ) + await page.evaluate(() => { + const debug = ( + window as typeof window & { + __terminalOutputSchedulerDebug?: { reset: () => void } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API is unavailable') + } + debug.reset() + }) + + const lagProbe = await startRendererLagProbe(page) + const tokens = terminals.map((_, index) => `HIDDEN_FLOOD_${index}_${Date.now()}`) + try { + await Promise.all( + terminals.map((terminal, index) => + callRuntime(page, 'terminal.send', { + terminal: terminal.terminal, + text: `FLOOD:${tokens[index]}`, + enter: true, + client: { id: 'paired-hidden-flood-e2e', type: 'desktop' } + }) + ) + ) + await expect + .poll( + async () => + Promise.all( + terminals.map(async (terminal, index) => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + page, + 'terminal.read', + { terminal: terminal.terminal, limit: 1_000 } + ) + return result.terminal.tail.join('\n').includes(`FLOODED:${tokens[index]}`) + }) + ), + { timeout: 30_000 } + ) + .toEqual(Array(terminals.length).fill(true)) + const hiddenFloodLagMs = await lagProbe.evaluate((probe) => probe.stop()) + const scheduler = await page.evaluate( + () => + ( + window as typeof window & { + __terminalOutputSchedulerDebug?: { + snapshot: () => { + backgroundEnqueueCount: number + queuedChars: number + scheduledDrainCount: number + } + } + } + ).__terminalOutputSchedulerDebug?.snapshot() ?? null + ) + expect(scheduler).not.toBeNull() + expect(scheduler?.backgroundEnqueueCount).toBe(0) + expect(scheduler?.scheduledDrainCount).toBe(0) + expect(scheduler?.queuedChars).toBe(0) + expect(hiddenFloodLagMs).toBeLessThan(MAX_HIDDEN_FLOOD_LAG_MS) + } finally { + await lagProbe.evaluate((probe) => probe.stop()).catch(() => undefined) + await lagProbe.dispose() + } + return tokens +} diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index 48444b79eb9..c49917cb24f 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' const FILL_ROWS = 6_000 +const FLOOD_ROWS = 4_000 function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'` @@ -30,6 +31,12 @@ export function createPairedTerminalParkingFixture(): { "process.stdin.setEncoding('utf8')", "process.stdin.on('data', (data) => {", ' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {', + " if (command.startsWith('FLOOD:')) {", + " const token = command.slice('FLOOD:'.length)", + ` for (let row = 0; row < ${FLOOD_ROWS}; row += 1) process.stdout.write(\`flood-${'${token}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, + ' process.stdout.write(`FLOODED:${token}\\r\\n`)', + ' continue', + ' }', " if (command === 'FILL') {", ` for (let row = 0; row < ${FILL_ROWS}; row += 1) process.stdout.write(\`fill-${'${marker}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, ' process.stdout.write(`FILLED:${marker}\\r\\n`)', diff --git a/tests/e2e/helpers/paired-terminal-parking-oracle.ts b/tests/e2e/helpers/paired-terminal-parking-oracle.ts index e976a55b0d9..070c73cceb1 100644 --- a/tests/e2e/helpers/paired-terminal-parking-oracle.ts +++ b/tests/e2e/helpers/paired-terminal-parking-oracle.ts @@ -10,6 +10,7 @@ import { startRendererLagProbe } from '../paired-runtime-retention-metrics' import { expect } from './orca-app' +import { verifyHiddenPairedTerminalOutputSuppression } from './paired-terminal-hidden-output-oracle' import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' import { getTerminalContent, waitForActivePanePtyId } from './terminal' @@ -133,17 +134,7 @@ export async function runPairedTerminalParkingOracle( } await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) - await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) - await expect - .poll( - () => - page.evaluate( - (ids) => ids.filter((id) => window.__paneManagers?.has(id)).length, - remoteTabs.map((tab) => tab.tabId) - ), - { timeout: 10_000 } - ) - .toBe(TARGET_WORKTREE_COUNT) + const hiddenFloodTokens = await verifyHiddenPairedTerminalOutputSuppression(page, remoteTabs) const baseline = await readPairedRetentionSample( page, remoteTabs.map((tab) => tab.tabId) @@ -217,6 +208,8 @@ export async function runPairedTerminalParkingOracle( if (!evicted) { throw new Error('Ordinary parking did not unmount a paired terminal') } + const hiddenFloodToken = + hiddenFloodTokens[remoteTabs.findIndex((tab) => tab.tabId === evicted.tabId)] const parkedMarker = `WHILE_PARKED_${Date.now()}` await callRuntime(page, 'terminal.send', { terminal: evicted.terminal, @@ -248,11 +241,18 @@ export async function runPairedTerminalParkingOracle( await restored.click() expect(await waitForActivePanePtyId(page, 30_000)).toBe(evicted.originalPtyId) await expect - .poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 }) - .toContain(`fill-${evicted.marker}-4000-`) - await expect - .poll(() => getTerminalContent(page, 1_000_000), { timeout: 30_000 }) - .toContain(`LIVE:${parkedMarker}`) + .poll( + async () => { + const content = await getTerminalContent(page, 1_000_000) + return [ + `flood-${hiddenFloodToken}-3999-`, + `FLOODED:${hiddenFloodToken}`, + `LIVE:${parkedMarker}` + ].map((marker) => content.split(marker).length - 1) + }, + { timeout: 30_000 } + ) + .toEqual([1, 1, 1]) const liveMarker = `AFTER_RETENTION_${Date.now()}` await callRuntime(page, 'terminal.send', { terminal: evicted.terminal, From aeda3c6e32b8abdc194591ed7330fb7439845bd0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:00:36 -0700 Subject: [PATCH 041/123] fix(browser): resolve grab mode race conditions and teardown (#11661) - Serialize grab mode operations to prevent concurrent state changes - Wait for guest registration with timeout when page hasn't registered yet - Tear down armed overlay when disabling before selection starts - Distinguish injection-failed from not-ready errors in UI - Track arm generations to cancel stale operations after page switches - Add localized error messages for each failure reason --- src/main/browser/browser-manager-grab.test.ts | 8 + src/main/browser/browser-manager.ts | 11 +- src/main/ipc/browser.test.ts | 258 ++++++++++++++++++ src/main/ipc/browser.ts | 65 ++++- .../browser-pane/useGrabMode.test.ts | 187 +++++++++++++ .../components/browser-pane/useGrabMode.ts | 72 ++++- src/renderer/src/i18n/locales/en.json | 6 + src/renderer/src/i18n/locales/es.json | 6 + src/renderer/src/i18n/locales/ja.json | 6 + src/renderer/src/i18n/locales/ko.json | 6 + src/renderer/src/i18n/locales/zh.json | 6 + src/shared/browser-grab-types.ts | 6 +- 12 files changed, 618 insertions(+), 19 deletions(-) diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 43652e6b9db..f1e5b650d13 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -195,6 +195,14 @@ describe('browserManager grab operations', () => { expect(selection.opId).toBe('op-1') }) + it('tears down an armed overlay when disabling before selection starts', async () => { + const result = await browserManager.setGrabMode('tab-1', false, guest) + + expect(result).toBe(true) + expect(guestExecuteJavaScriptMock).toHaveBeenCalledTimes(1) + expect(guestExecuteJavaScriptMock.mock.calls[0][0]).toContain('grab.cleanup()') + }) + it('returns false if injection fails', async () => { guestExecuteJavaScriptMock.mockRejectedValue(new Error('Injection failed')) const result = await browserManager.setGrabMode('tab-1', true, guest) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 63ef02efcb4..7dafca617f2 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1610,8 +1610,17 @@ export class BrowserManager { guest: Electron.WebContents ): Promise { if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) this.cancelGrabOp(browserTabId, 'user') - return true + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } } // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. try { diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 7651d130651..c09fb2d7f80 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -8,6 +8,8 @@ const { getGuestWebContentsIdMock, getWebContentsIdByTabIdMock, getWorktreeIdForTabMock, + getAuthorizedGuestMock, + setGrabModeMock, openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, @@ -22,6 +24,8 @@ const { getGuestWebContentsIdMock: vi.fn(), getWebContentsIdByTabIdMock: vi.fn(() => new Map()), getWorktreeIdForTabMock: vi.fn(), + getAuthorizedGuestMock: vi.fn(), + setGrabModeMock: vi.fn(), openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), @@ -53,6 +57,8 @@ vi.mock('../browser/browser-manager', () => ({ getGuestWebContentsId: getGuestWebContentsIdMock, getWebContentsIdByTabId: getWebContentsIdByTabIdMock, getWorktreeIdForTab: getWorktreeIdForTabMock, + getAuthorizedGuest: getAuthorizedGuestMock, + setGrabMode: setGrabModeMock, openDevTools: openDevToolsMock, setAnnotationViewportBridge: setAnnotationViewportBridgeMock, cancelDownload: cancelDownloadMock @@ -79,6 +85,9 @@ describe('registerBrowserHandlers', () => { getWebContentsIdByTabIdMock.mockReset() getWebContentsIdByTabIdMock.mockReturnValue(new Map()) getWorktreeIdForTabMock.mockReset() + getAuthorizedGuestMock.mockReset() + setGrabModeMock.mockReset() + setGrabModeMock.mockResolvedValue(true) openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() @@ -331,6 +340,255 @@ describe('registerBrowserHandlers', () => { } }) + it('waits for authorized registration even when an old guest is still live', async () => { + const guest = { id: 123 } as Electron.WebContents + getGuestWebContentsIdMock.mockReturnValue(122) + getAuthorizedGuestMock.mockReturnValueOnce(null).mockReturnValue(guest) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const pendingResult = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await Promise.resolve() + expect(setGrabModeMock).not.toHaveBeenCalled() + + expect( + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + ).toBe(true) + + await expect(pendingResult).resolves.toEqual({ ok: true }) + expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) + }) + + it('returns not-ready when grab registration does not arrive', async () => { + vi.useFakeTimers() + try { + getGuestWebContentsIdMock.mockReturnValue(null) + getAuthorizedGuestMock.mockReturnValue(null) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + const pendingResult = setGrabModeHandler( + { sender }, + { browserPageId: 'page-1', enabled: true } + ) + await vi.advanceTimersByTimeAsync(1_001) + + await expect(pendingResult).resolves.toEqual({ ok: false, reason: 'not-ready' }) + expect(setGrabModeMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not enable grab mode after a pending request is cancelled', async () => { + const guest = { id: 123 } as Electron.WebContents + let registered = false + getAuthorizedGuestMock.mockImplementation(() => (registered ? guest : null)) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const pendingEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await expect( + setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + ).resolves.toEqual({ ok: true }) + + registered = true + expect( + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + ).toBe(true) + + await expect(pendingEnable).resolves.toEqual({ ok: true }) + expect(setGrabModeMock).not.toHaveBeenCalled() + }) + + it('coalesces a cancelled pending enable into one later enable', async () => { + const guest = { id: 123 } as Electron.WebContents + let registered = false + getAuthorizedGuestMock.mockImplementation(() => (registered ? guest : null)) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { + browserPageId: string + workspaceId: string + worktreeId: string + webContentsId: number + } + ) => boolean + + const firstEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + const latestEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + + registered = true + registerHandler( + { sender }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + await expect(Promise.all([firstEnable, latestEnable])).resolves.toEqual([ + { ok: true }, + { ok: true } + ]) + expect(setGrabModeMock).toHaveBeenCalledTimes(1) + expect(setGrabModeMock).toHaveBeenCalledWith('page-1', true, guest) + }) + + it('serializes in-flight mode changes so a stale enable cannot tear down the latest one', async () => { + const guest = { id: 123 } as Electron.WebContents + let resolveFirstEnable!: (success: boolean) => void + const firstEnable = new Promise((resolve) => { + resolveFirstEnable = resolve + }) + getAuthorizedGuestMock.mockReturnValue(guest) + setGrabModeMock.mockReturnValueOnce(firstEnable).mockResolvedValue(true) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + const staleEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + await vi.waitFor(() => { + expect(setGrabModeMock).toHaveBeenCalledTimes(1) + }) + const disable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: false }) + const latestEnable = setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + + resolveFirstEnable(true) + await expect(Promise.all([staleEnable, disable, latestEnable])).resolves.toEqual([ + { ok: true }, + { ok: true }, + { ok: true } + ]) + + expect(setGrabModeMock.mock.calls).toEqual([ + ['page-1', true, guest], + ['page-1', true, guest] + ]) + }) + + it('distinguishes picker injection failure from guest readiness', async () => { + const guest = { id: 123 } as Electron.WebContents + getAuthorizedGuestMock.mockReturnValue(guest) + setGrabModeMock.mockResolvedValue(false) + registerBrowserHandlers() + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + const setGrabModeHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:setGrabMode' + )?.[1] as ( + event: { sender: Electron.WebContents }, + args: { browserPageId: string; enabled: boolean } + ) => Promise + + await expect( + setGrabModeHandler({ sender }, { browserPageId: 'page-1', enabled: true }) + ).resolves.toEqual({ ok: false, reason: 'injection-failed' }) + }) + it('resolves worktree and any-tab registration waiters when a guest registers', async () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index f5c1c8fb268..246e821a25a 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -45,6 +45,9 @@ let agentBrowserBridgeRef: AgentBrowserBridge | null = null const pendingTabRegistrations = new Map void>>() const pendingWorktreeTabRegistrations = new Map void>>() const pendingAnyTabRegistrations = new Set<() => void>() +const grabModeIntentByPageId = new Map() +const grabModeOperationByPageId = new Map>() +const GRAB_REGISTRATION_WAIT_MS = 1_000 function waitForRegistrationSet( registrationResolvers: Set<() => void>, @@ -100,6 +103,10 @@ export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000) if (isLiveBrowserWebContentsId(browserManager.getGuestWebContentsId(browserPageId))) { return Promise.resolve() } + return waitForNextTabRegistration(browserPageId, timeoutMs) +} + +function waitForNextTabRegistration(browserPageId: string, timeoutMs: number): Promise { let registrationResolvers = pendingTabRegistrations.get(browserPageId) if (!registrationResolvers) { registrationResolvers = new Set() @@ -110,6 +117,24 @@ export function waitForTabRegistration(browserPageId: string, timeoutMs = 8_000) }) } +function queueGrabModeOperation( + browserPageId: string, + operation: () => Promise +): Promise { + const previous = grabModeOperationByPageId.get(browserPageId) ?? Promise.resolve() + const result = previous.then(operation) + const completion = result.then( + () => {}, + () => {} + ) + grabModeOperationByPageId.set(browserPageId, completion) + return result.finally(() => { + if (grabModeOperationByPageId.get(browserPageId) === completion) { + grabModeOperationByPageId.delete(browserPageId) + } + }) +} + export function waitForWorktreeTabRegistration( worktreeId: string | undefined, timeoutMs = 8_000 @@ -168,6 +193,7 @@ function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { } export function registerBrowserHandlers(): void { + grabModeIntentByPageId.clear() ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') @@ -237,6 +263,7 @@ export function registerBrowserHandlers(): void { agentBrowserBridgeRef.onTabClosed(wcId) } browserManager.unregisterGuest(args.browserPageId) + grabModeIntentByPageId.delete(args.browserPageId) return true }) @@ -370,12 +397,44 @@ export function registerBrowserHandlers(): void { if (!isTrustedBrowserRenderer(event.sender)) { return { ok: false, reason: 'not-authorized' } } - const guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + const intent = { + generation: (grabModeIntentByPageId.get(args.browserPageId)?.generation ?? 0) + 1, + enabled: args.enabled + } + grabModeIntentByPageId.set(args.browserPageId, intent) + const isCurrentIntent = (): boolean => + grabModeIntentByPageId.get(args.browserPageId) === intent + let guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest && args.enabled) { + // Why: fast file:// pages can expose the toolbar before did-attach registration reaches main. + await waitForNextTabRegistration(args.browserPageId, GRAB_REGISTRATION_WAIT_MS).catch( + () => {} + ) + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + } if (!guest) { + if (!args.enabled) { + return { ok: true } + } return { ok: false, reason: 'not-ready' } } - const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) - return success ? { ok: true } : { ok: false, reason: 'not-ready' } + return queueGrabModeOperation(args.browserPageId, async () => { + if (!isCurrentIntent()) { + return { ok: true } + } + guest = browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id) + if (!guest) { + return args.enabled ? { ok: false, reason: 'not-ready' } : { ok: true } + } + const success = await browserManager.setGrabMode(args.browserPageId, args.enabled, guest) + if (!isCurrentIntent()) { + return { ok: true } + } + return success ? { ok: true } : { ok: false, reason: 'injection-failed' } + }) } ) diff --git a/src/renderer/src/components/browser-pane/useGrabMode.test.ts b/src/renderer/src/components/browser-pane/useGrabMode.test.ts index 048a3dc916a..e912d74a0cb 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.test.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.test.ts @@ -84,4 +84,191 @@ describe('useGrabMode', () => { enabled: true }) }) + + it('reports a picker injection failure without calling it a readiness error', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { + setGrabMode: vi.fn(async () => ({ ok: false, reason: 'injection-failed' })), + awaitGrabSelection: vi.fn(), + cancelGrab: vi.fn() + } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + render().toggle() + await Promise.resolve() + + const grab = render() + expect(grab.state).toBe('error') + expect(grab.error).toBe('Could not start element selection on this page.') + }) + + it('arms immediately and treats a second toggle as cancellation while enable is pending', async () => { + const harness = createReactHookHarness() + let resolveEnable!: (result: { ok: true }) => void + const pendingEnable = new Promise<{ ok: true }>((resolve) => { + resolveEnable = resolve + }) + const setGrabMode = vi.fn(async ({ enabled }: { enabled: boolean }) => + enabled ? pendingEnable : ({ ok: true } as const) + ) + const awaitGrabSelection = vi.fn(() => new Promise(() => {})) + const cancelGrab = vi.fn() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { setGrabMode, awaitGrabSelection, cancelGrab } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + const grab = render() + grab.toggle() + expect(render().state).toBe('armed') + + grab.toggle() + expect(setGrabMode.mock.calls.filter(([args]) => args.enabled)).toHaveLength(1) + expect(render().state).toBe('idle') + + resolveEnable({ ok: true }) + await pendingEnable + await Promise.resolve() + + expect(awaitGrabSelection).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + }) + }) + + it('cancels the tab owning the grab when the page changes before effects run', async () => { + const harness = createReactHookHarness() + const setGrabMode = vi.fn(async () => ({ ok: true })) + const awaitGrabSelection = vi.fn(() => new Promise(() => {})) + const cancelGrab = vi.fn() + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { setGrabMode, awaitGrabSelection, cancelGrab } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = (browserPageId: string) => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode(browserPageId) + } + + render('page-1').toggle() + await vi.waitFor(() => { + expect(awaitGrabSelection).toHaveBeenCalledTimes(1) + }) + + render('page-2').cancel() + + expect(setGrabMode).toHaveBeenLastCalledWith({ + browserPageId: 'page-1', + enabled: false + }) + expect(cancelGrab).toHaveBeenCalledWith({ browserPageId: 'page-1' }) + }) + + it('ignores a stale screenshot after restarting on the same page', async () => { + const harness = createReactHookHarness() + let resolveScreenshot!: (result: { + ok: true + screenshot: { dataUrl: string; width: number; height: number } + }) => void + const pendingScreenshot = new Promise<{ + ok: true + screenshot: { dataUrl: string; width: number; height: number } + }>((resolve) => { + resolveScreenshot = resolve + }) + const awaitGrabSelection = vi + .fn() + .mockResolvedValueOnce({ + kind: 'selected', + payload: { + target: { + rectViewport: { x: 0, y: 0, width: 1, height: 1 } + } + } + }) + .mockImplementation(() => new Promise(() => {})) + const captureSelectionScreenshot = vi.fn(() => pendingScreenshot) + vi.doMock('react', () => harness.react) + vi.doMock('@/hooks/useMountedRef', () => ({ + useMountedRef: () => ({ current: true }) + })) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + browser: { + setGrabMode: vi.fn(async () => ({ ok: true })), + awaitGrabSelection, + captureSelectionScreenshot, + cancelGrab: vi.fn() + } + } + }) + const { useGrabMode } = await import('./useGrabMode') + const render = () => { + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + return useGrabMode('page-1') + } + + render().toggle() + await vi.waitFor(() => { + expect(captureSelectionScreenshot).toHaveBeenCalledTimes(1) + }) + + render().cancel() + render().toggle() + await vi.waitFor(() => { + expect(awaitGrabSelection).toHaveBeenCalledTimes(2) + }) + + resolveScreenshot({ + ok: true, + screenshot: { dataUrl: 'data:image/png;base64,AA==', width: 1, height: 1 } + }) + await pendingScreenshot + + await vi.waitFor(() => { + const grab = render() + expect(grab.state).toBe('awaiting') + expect(grab.payload).toBeNull() + }) + }) }) diff --git a/src/renderer/src/components/browser-pane/useGrabMode.ts b/src/renderer/src/components/browser-pane/useGrabMode.ts index ccf9851ffb5..b3ee2580572 100644 --- a/src/renderer/src/components/browser-pane/useGrabMode.ts +++ b/src/renderer/src/components/browser-pane/useGrabMode.ts @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { BrowserGrabPayload, + BrowserGrabRejectReason, BrowserGrabScreenshot } from '../../../../shared/browser-grab-types' import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' import { isEditableKeyboardTarget } from './browser-keyboard' // --------------------------------------------------------------------------- @@ -32,6 +34,31 @@ function nextOpId(): string { return `grab-${++opIdCounter}-${Date.now()}` } +function getGrabEnableError(reason: BrowserGrabRejectReason): string { + switch (reason) { + case 'not-ready': + return translate( + 'auto.components.browser-pane.grab.errorNotReady', + "This page isn't ready for element selection yet." + ) + case 'not-authorized': + return translate( + 'auto.components.browser-pane.grab.errorNotAuthorized', + 'Browser access was denied.' + ) + case 'already-active': + return translate( + 'auto.components.browser-pane.grab.errorAlreadyActive', + 'Element selection is already active.' + ) + case 'injection-failed': + return translate( + 'auto.components.browser-pane.grab.errorInjectionFailed', + 'Could not start element selection on this page.' + ) + } +} + /** * Hook that drives the browser grab lifecycle for a single browser page. * @@ -45,6 +72,7 @@ export function useGrabMode(browserPageId: string): GrabModeHook { const [contextMenu, setContextMenu] = useState(false) const activeOpIdRef = useRef(null) const grabTabIdRef = useRef(null) + const armGenerationRef = useRef(0) const browserTabIdRef = useRef(browserPageId) // Why: toolbar/key handlers from the latest render can fire before passive // effects run after a page switch, so keep the target page current in render. @@ -58,6 +86,7 @@ export function useGrabMode(browserPageId: string): GrabModeHook { return () => { const grabTabId = grabTabIdRef.current if (grabTabId) { + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ browserPageId: grabTabId, enabled: false }) void window.api.browser.cancelGrab({ browserPageId: grabTabId }) grabTabIdRef.current = null @@ -68,7 +97,9 @@ export function useGrabMode(browserPageId: string): GrabModeHook { const armAndAwait = useCallback(async () => { const tabId = browserTabIdRef.current + const armGeneration = (armGenerationRef.current += 1) grabTabIdRef.current = tabId + setState('armed') // Enable grab mode — injects the overlay const setResult = await window.api.browser.setGrabMode({ @@ -77,25 +108,28 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }) if ( !mountedRef.current || + armGenerationRef.current !== armGeneration || browserTabIdRef.current !== tabId || grabTabIdRef.current !== tabId ) { - void window.api.browser.setGrabMode({ browserPageId: tabId, enabled: false }) - void window.api.browser.cancelGrab({ browserPageId: tabId }) - if (grabTabIdRef.current === tabId) { - grabTabIdRef.current = null + const supersededBySameTab = + armGenerationRef.current !== armGeneration && grabTabIdRef.current === tabId + if (!supersededBySameTab) { + void window.api.browser.setGrabMode({ browserPageId: tabId, enabled: false }) + void window.api.browser.cancelGrab({ browserPageId: tabId }) + if (grabTabIdRef.current === tabId) { + grabTabIdRef.current = null + } } return } if (!setResult.ok) { grabTabIdRef.current = null setState('error') - setError(`Cannot enable grab mode: ${setResult.reason}`) + setError(getGrabEnableError(setResult.reason)) return } - setState('armed') - // Generate opId and await selection const opId = nextOpId() activeOpIdRef.current = opId @@ -127,7 +161,11 @@ export function useGrabMode(browserPageId: string): GrabModeHook { } catch { // Screenshot failure is non-fatal } - if (!mountedRef.current || grabTabIdRef.current !== tabId) { + if ( + !mountedRef.current || + armGenerationRef.current !== armGeneration || + grabTabIdRef.current !== tabId + ) { return } @@ -146,20 +184,22 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [mountedRef]) const toggle = useCallback(() => { - if (state === 'idle' || state === 'error') { + if ((state === 'idle' || state === 'error') && grabTabIdRef.current === null) { setError(null) setPayload(null) setContextMenu(false) void armAndAwait() } else { // Disable grab mode + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserPageId: browserTabIdRef.current + browserPageId: targetTabId }) activeOpIdRef.current = null } @@ -172,13 +212,15 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [state, armAndAwait]) const cancel = useCallback(() => { + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) if (activeOpIdRef.current) { void window.api.browser.cancelGrab({ - browserPageId: browserTabIdRef.current + browserPageId: targetTabId }) activeOpIdRef.current = null } @@ -204,8 +246,10 @@ export function useGrabMode(browserPageId: string): GrabModeHook { }, [armAndAwait]) const exit = useCallback(() => { + const targetTabId = grabTabIdRef.current ?? browserTabIdRef.current + armGenerationRef.current += 1 void window.api.browser.setGrabMode({ - browserPageId: browserTabIdRef.current, + browserPageId: targetTabId, enabled: false }) // Why: clear the active opId so that any in-flight result from the diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index edc3de12f21..9e1cd986862 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14327,6 +14327,12 @@ }, "undo": "Undo", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "This page isn't ready for element selection yet.", + "errorNotAuthorized": "Browser access was denied.", + "errorAlreadyActive": "Element selection is already active.", + "errorInjectionFailed": "Could not start element selection on this page." } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ee83b6d36a4..ae9d9dcff4a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14267,6 +14267,12 @@ }, "undo": "Deshacer", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "Esta página aún no está lista para seleccionar elementos.", + "errorNotAuthorized": "Se denegó el acceso al navegador.", + "errorAlreadyActive": "La selección de elementos ya está activa.", + "errorInjectionFailed": "No se pudo iniciar la selección de elementos en esta página." } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index de0d97977c8..ac17b89b683 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14267,6 +14267,12 @@ }, "undo": "元に戻す", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "このページではまだ要素を選択できません。", + "errorNotAuthorized": "ブラウザーへのアクセスが拒否されました。", + "errorAlreadyActive": "要素の選択はすでに有効です。", + "errorInjectionFailed": "このページで要素の選択を開始できませんでした。" } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2670e6df975..b483967528b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14267,6 +14267,12 @@ }, "undo": "실행 취소", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "이 페이지에서는 아직 요소를 선택할 수 없습니다.", + "errorNotAuthorized": "브라우저 접근이 거부되었습니다.", + "errorAlreadyActive": "요소 선택이 이미 활성화되어 있습니다.", + "errorInjectionFailed": "이 페이지에서 요소 선택을 시작할 수 없습니다." } }, "pluginCatalog": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 79ac74754d3..75e5cea0482 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14267,6 +14267,12 @@ }, "undo": "撤销", "widthOption": "{{value0}} px" + }, + "grab": { + "errorNotReady": "此页面尚未准备好进行元素选择。", + "errorNotAuthorized": "浏览器访问被拒绝。", + "errorAlreadyActive": "元素选择已处于活动状态。", + "errorInjectionFailed": "无法在此页面上启动元素选择。" } }, "pluginCatalog": { diff --git a/src/shared/browser-grab-types.ts b/src/shared/browser-grab-types.ts index f1dd59656a8..09a0ae07bc8 100644 --- a/src/shared/browser-grab-types.ts +++ b/src/shared/browser-grab-types.ts @@ -121,7 +121,11 @@ export type BrowserSetGrabModeArgs = { } /** Why a grab IPC call was rejected before the operation could start. */ -export type BrowserGrabRejectReason = 'not-ready' | 'not-authorized' | 'already-active' +export type BrowserGrabRejectReason = + | 'not-ready' + | 'not-authorized' + | 'already-active' + | 'injection-failed' export type BrowserSetGrabModeResult = { ok: true } | { ok: false; reason: BrowserGrabRejectReason } From 0515e57c60cfb18ee136ab4e74de185abbcdc725 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:05:13 -0700 Subject: [PATCH 042/123] fix(browser): clear grab mode operation queue on teardown (#11679) A pending grab mode chain could outlive its guest: registerBrowserHandlers() and browser:unregisterGuest cleared grabModeIntentByPageId but left grabModeOperationByPageId intact. An in-flight executeJavaScript against a destroyed guest would then block every later operation queued behind it for that page, including after a workspace restart or browserPageId reuse. Addresses review feedback on #11661. Co-authored-by: Claude Opus 5 --- src/main/ipc/browser.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 246e821a25a..fb67b002234 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -194,6 +194,8 @@ function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { export function registerBrowserHandlers(): void { grabModeIntentByPageId.clear() + // Why: a stale in-flight chain from a prior registration would block new operations forever. + grabModeOperationByPageId.clear() ipcMain.removeHandler('browser:registerGuest') ipcMain.removeHandler('browser:unregisterGuest') ipcMain.removeHandler('browser:openDevTools') @@ -264,6 +266,8 @@ export function registerBrowserHandlers(): void { } browserManager.unregisterGuest(args.browserPageId) grabModeIntentByPageId.delete(args.browserPageId) + // Why: don't let a reused browserPageId queue behind the destroyed guest's pending chain. + grabModeOperationByPageId.delete(args.browserPageId) return true }) From 239c027693225a2d10eb83142df5b950f7d32f91 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:45:44 -0700 Subject: [PATCH 043/123] Keep Create PR intent running when review lookup is unavailable (#11678) * Fix Create PR preparation with unavailable lookup * test(source-control): align dirty+unavailable intent expectation Create PR preparation is allowed when review lookup is unavailable; only final create stays fail-closed. Update the local-blocker snapshot test to match. * Keep Create PR intent running when hosted review lookup fails A failed or timed-out hosted-review eligibility lookup no longer aborts Create PR intent mid-run. Local prep (stage/commit/push) continues, the branch-ahead refresh is deferred until after eligibility resolves, and the final create preflight still fails closed to prevent duplicate reviews. Also gate generated PR title/body on eligibility and thread the provider through the intent run token so an unavailable lookup falls back to the inferred remote host. * fix(source-control): align dirty+unavailable intent expectation Local preparation (stage/commit changes) is safe without review-lookup authority; remote actions stay blocked. Prevents dirty trees from dead-ending at sync-first when lookup is unavailable. * fix(source-control): distinguish loading state from unavailable lookup Require head branch presence in shouldAttemptCreateHostedReviewForIntent to separate real unavailable-lookup results from loading placeholders, which share the same outcome/reason pair but lack a branch name. * test(activity): drive portal readiness latch release with explicit rAF Wall-clock setTimeout waits for requestAnimationFrame were flaky under CI load (shard 15/16), leaving status stuck at loading instead of ready. * Distinguish expected absence from git errors in remote removal Why: swallowing all errors silently masks genuine git failures. Check presence explicitly instead, so setup/teardown can still skip when origin is absent while letting real errors surface. --- ...ty-portal-readiness-loop.react185.test.tsx | 13 +- .../right-sidebar/SourceControl.tsx | 106 ++++-- ...urce-control-create-pr-intent-flow.test.ts | 47 ++- .../source-control-create-pr-intent-flow.ts | 25 +- ...ntrol-create-review-blocked-action.test.ts | 48 ++- ...ce-control-create-review-blocked-action.ts | 10 + ...ol-dropdown-items.create-pr-intent.test.ts | 190 ++++++++++ .../source-control-dropdown-items.test.ts | 119 ------ ...view-creation-eligibility-snapshot.test.ts | 89 ++++- ...ed-review-creation-eligibility-snapshot.ts | 96 +++-- ...ol-primary-action.create-pr-intent.test.ts | 26 ++ ...ource-control-create-review-intent.test.ts | 165 +++++++++ .../source-control-create-review-intent.ts | 14 +- ...ce-control-create-pr-intent-switch.spec.ts | 344 ++++++++++++++++++ ...ource-control-pr-generation-switch.spec.ts | 190 ---------- 15 files changed, 1100 insertions(+), 382 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts create mode 100644 src/shared/source-control-create-review-intent.test.ts create mode 100644 tests/e2e/source-control-create-pr-intent-switch.spec.ts diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 11e094472a6..916abbdcb69 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -307,6 +307,8 @@ describe('Activity portal pane switching', () => { }) it('releases a latched readiness once the terminal attaches', async () => { + // Drive rAF explicitly — wall-clock waits flake under CI load. + const frames = installAnimationFrameController() const target = document.createElement('div') document.body.append(target) const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => { @@ -352,15 +354,22 @@ describe('Activity portal pane switching', () => { root = createRoot(document.createElement('div')) await act(async () => { root.render() - await new Promise((resolve) => setTimeout(resolve, 180)) }) + for (let frame = 0; frame < 40; frame += 1) { + if (frames.pending() === 0 && statuses.at(-1) === 'unavailable') { + break + } + await frames.flush() + } expect(statuses.at(-1)).toBe('unavailable') churning = false await act(async () => { buildRoot('ready') - await new Promise((resolve) => setTimeout(resolve, 40)) }) + for (let frame = 0; frame < 10 && statuses.at(-1) !== 'ready'; frame += 1) { + await frames.flush() + } expect(statuses.at(-1)).toBe('ready') }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index d349d789aa0..6f5a4cc5fb1 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -265,11 +265,14 @@ import { getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, + shouldAttemptCreateHostedReviewForIntent, + shouldGenerateHostedReviewDetailsForIntent, type CreatePrIntentRunToken } from './source-control-create-pr-intent-flow' import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state' import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action' import { + buildCreatePrIntentUnavailableEligibility, buildLoadingHostedReviewCreationEligibility, buildLocalBlockerHostedReviewCreationEligibility, resolveHostedReviewCreationProviderForTarget @@ -1580,6 +1583,7 @@ function SourceControlInner(): React.JSX.Element { resolveHostedReviewCreationProviderForTarget( hostedReviewCreationProviderHintRef.current, { repoId: activeRepoId, worktreeId: activeWorktreeId ?? null, branch: branchName }, + // Why: provisional already infers the remote host and defaults to github; never fall back to unsupported mid-load. provisionalHostedReviewProvider ) ) @@ -3409,7 +3413,7 @@ function SourceControlInner(): React.JSX.Element { token: CreatePrIntentRunToken, eligibility: HostedReviewCreationEligibility ): Promise => { - if (!activeRepo || !token.branch || !eligibility.canCreate) { + if (!activeRepo || !token.branch || !shouldAttemptCreateHostedReviewForIntent(eligibility)) { return false } @@ -3441,6 +3445,7 @@ function SourceControlInner(): React.JSX.Element { } if ( + shouldGenerateHostedReviewDetailsForIntent(eligibility) && hasConfiguredSourceControlTextGenerationDefaults({ actionId: 'pullRequest', settings, @@ -3651,23 +3656,43 @@ function SourceControlInner(): React.JSX.Element { if (!activeRepo || !token.branch) { return null } - const result = await getHostedReviewCreationEligibility({ - repoPath: activeRepo.path, - repoId: activeRepo.id, - worktreePath: token.worktreePath, - branch: token.branch, - base: token.baseRef ?? null, - hasUncommittedChanges, - hasUpstream: upstreamStatus?.hasUpstream, - ahead: upstreamStatus?.ahead, - behind: upstreamStatus?.behind, - linkedGitHubPR, - fallbackGitHubPR: fallbackGitHubPRNumber, - linkedGitLabMR, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR - }) + let result: HostedReviewCreationEligibility + try { + result = await getHostedReviewCreationEligibility({ + repoPath: activeRepo.path, + repoId: activeRepo.id, + worktreePath: token.worktreePath, + branch: token.branch, + base: token.baseRef ?? null, + hasUncommittedChanges, + hasUpstream: upstreamStatus?.hasUpstream, + ahead: upstreamStatus?.ahead, + behind: upstreamStatus?.behind, + linkedGitHubPR, + fallbackGitHubPR: fallbackGitHubPRNumber, + linkedGitLabMR, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR + }) + } catch (error) { + console.warn('[SourceControl] Create PR intent eligibility failed', error) + // Why: when local status still yields a prep step (dirty/push/sync), keep the intent + // moving. If nothing actionable can be synthesized, rethrow so the outer intent + // catch surfaces a retry notice instead of leaving "Preparing…" stuck forever. + const fallback = buildCreatePrIntentUnavailableEligibility(token.provider, { + branch: token.branch, + baseRef: token.baseRef, + hasUncommittedChanges, + hasUpstream: upstreamStatus?.hasUpstream, + ahead: upstreamStatus?.ahead, + behind: upstreamStatus?.behind + }) + if (!fallback) { + throw error + } + result = fallback + } setHostedReviewCreationState({ repoId: activeRepo.id, worktreeId: token.worktreeId, @@ -3739,6 +3764,9 @@ function SourceControlInner(): React.JSX.Element { worktreeId: activeWorktreeId, worktreePath, branch: branchName, + // Why: token carries the same provisional provider used for UI copy so a failed + // eligibility IPC can synthesize local prep steps for the correct host. + provider: provisionalHostedReviewProvider, // Why: intent crosses async commit/push steps, so the base stays tied to what was selected when the run started. baseRef: effectiveBaseRef ?? null }) @@ -3939,19 +3967,25 @@ function SourceControlInner(): React.JSX.Element { } } - const branchAhead = await refreshBranchCompareForCreatePrIntent(token) - if (abortIfStale()) { - return - } let eligibility = await readHostedReviewCreationEligibilityForIntent({ token, hasUncommittedChanges: latestStatusEntries.length > 0, upstreamStatus: latestUpstreamStatus }) - if (abortIfStale() || !eligibility) { + if (abortIfStale()) { return } - if (eligibility.canCreate) { + if (!eligibility) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.d7492cafce', + 'Could not refresh Source Control. Retry Create PR.' + ) + }) + return + } + if (shouldAttemptCreateHostedReviewForIntent(eligibility)) { await createHostedReviewForCreatePrIntent(token, eligibility) if (abortIfStale()) { return @@ -3963,6 +3997,13 @@ function SourceControlInner(): React.JSX.Element { return } + const branchAhead = + eligibility.blockedReason === 'no_upstream' + ? await refreshBranchCompareForCreatePrIntent(token) + : undefined + if (abortIfStale()) { + return + } const remoteStep = resolveCreatePrIntentRemoteStep({ upstreamStatus: latestUpstreamStatus, hostedReviewCreation: eligibility, @@ -4047,19 +4088,23 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } - if (eligibility?.canCreate) { + if (eligibility && shouldAttemptCreateHostedReviewForIntent(eligibility)) { await createHostedReviewForCreatePrIntent(token, eligibility) if (abortIfStale()) { return } return } + // Why: prefer the blocked-reason notice (incl. unavailable lookup) over a generic stop. + const blockedNotice = resolveBlockedCreateReviewNoticeMessage(eligibility) setCreatePrIntentNoticeForWorktree(token.worktreeId, { - tone: 'muted', - message: translate( - 'auto.components.right.sidebar.SourceControl.995c5e67ec', - 'Review setup needs attention.' - ) + tone: blockedNotice ? 'destructive' : 'muted', + message: + blockedNotice ?? + translate( + 'auto.components.right.sidebar.SourceControl.995c5e67ec', + 'Review setup needs attention.' + ) }) } catch (error) { console.warn('[SourceControl] Create PR intent failed', error) @@ -4106,6 +4151,7 @@ function SourceControlInner(): React.JSX.Element { readHostedReviewCreationEligibilityForIntent, refreshGitStatusForCreatePrIntent, refreshBranchCompareForCreatePrIntent, + provisionalHostedReviewProvider, remoteStatus, runRemoteAction, setCreatePrIntentNoticeForWorktree, diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 753ebaaa750..c28e428fb4d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -8,7 +8,9 @@ import { getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, resolveCreatePrIntentReviewBase, - resolveCreatePrIntentRemoteStep + resolveCreatePrIntentRemoteStep, + shouldAttemptCreateHostedReviewForIntent, + shouldGenerateHostedReviewDetailsForIntent } from './source-control-create-pr-intent-flow' import type { GitStatusEntry } from '../../../../shared/types' @@ -21,6 +23,7 @@ describe('source-control Create PR intent flow helpers', () => { worktreeId: 'wt-1', worktreePath: '/repo', branch: 'feature', + provider: 'github', baseRef: 'origin/main' }) @@ -44,7 +47,8 @@ describe('source-control Create PR intent flow helpers', () => { repoId: 'repo-1', worktreeId: 'wt-1', worktreePath: '/repo', - branch: 'feature/pr' + branch: 'feature/pr', + provider: 'github' }) expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe( @@ -63,7 +67,8 @@ describe('source-control Create PR intent flow helpers', () => { repoId: 'repo-1', worktreeId: 'wt-1', worktreePath: wt1Path, - branch: 'feature/pr' + branch: 'feature/pr', + provider: 'github' }) expect( @@ -92,6 +97,7 @@ describe('source-control Create PR intent flow helpers', () => { worktreeId: 'wt-1', worktreePath, branch: 'feature/pr', + provider: 'github', baseRef: 'refs/remotes/origin/main' }) @@ -283,6 +289,41 @@ describe('source-control Create PR intent flow helpers', () => { ).toBe('blocked') }) + it('uses main preflight as the final lookup authority after preparation', () => { + const unavailable = { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' as const, + head: 'feature-branch' + } + expect(shouldAttemptCreateHostedReviewForIntent(unavailable)).toBe(true) + // Loading placeholders share the unavailable/null-reason shape but carry no branch. + expect(shouldAttemptCreateHostedReviewForIntent({ ...unavailable, head: undefined })).toBe( + false + ) + expect(shouldGenerateHostedReviewDetailsForIntent(unavailable)).toBe(false) + expect( + shouldGenerateHostedReviewDetailsForIntent({ + ...unavailable, + canCreate: true, + reviewLookupOutcome: 'not_found' + }) + ).toBe(true) + expect( + shouldAttemptCreateHostedReviewForIntent({ + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'unavailable' + }) + ).toBe(false) + }) + it('surfaces the commit failure summary in the Create PR intent notice', () => { expect( getCreatePrIntentCommitFailureNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index bafba4c8504..03abd03a755 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -2,7 +2,10 @@ import { isBehindOnlyUpstream, shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' -import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import type { + HostedReviewCreationEligibility, + HostedReviewProvider +} from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef @@ -24,6 +27,7 @@ export type CreatePrIntentRunToken = { worktreeId: string worktreePath: string branch: string + provider: HostedReviewProvider baseRef?: string | null startedAt: number } @@ -162,6 +166,25 @@ export function resolveCreatePrIntentRemoteStep({ return 'none' } +export function shouldAttemptCreateHostedReviewForIntent( + eligibility: HostedReviewCreationEligibility +): boolean { + return ( + eligibility.canCreate || + // Why: `head` separates a real unavailable-lookup result from a loading + // placeholder, which carries the same outcome/reason pair but no branch. + (eligibility.reviewLookupOutcome === 'unavailable' && + eligibility.blockedReason === null && + Boolean(eligibility.head?.trim())) + ) +} + +export function shouldGenerateHostedReviewDetailsForIntent( + eligibility: HostedReviewCreationEligibility +): boolean { + return eligibility.canCreate +} + export function getCreatePrIntentCommitFailureNoticeMessage( commitError: string | null | undefined, copy: { diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts index e2bd937f1c2..c40468b9e49 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { canClickBlockedCreateReviewReason, - resolveBlockedCreateReviewNoticeMessage + resolveBlockedCreateReviewNoticeMessage, + resolveUnavailableCreateReviewLookupNoticeMessage } from './source-control-create-review-blocked-action' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' @@ -62,6 +63,51 @@ describe('source-control-create-review-blocked-action', () => { ) }) + it('preserves a known dirty-tree prerequisite when review lookup is unavailable', () => { + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + blockedReason: 'dirty', + nextAction: 'commit', + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe('Create PR failed: commit or discard local changes before creating a pull request.') + }) + + it('preserves authentication guidance when review lookup is unavailable', () => { + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + provider: 'gitlab', + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe( + 'Create MR failed: GitLab is not authenticated. Next step: Run glab auth login in this environment.' + ) + }) + + it('reports unavailable review lookup authority when no local blocker is known', () => { + expect(resolveUnavailableCreateReviewLookupNoticeMessage('gitlab')).toBe( + 'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.' + ) + expect( + resolveBlockedCreateReviewNoticeMessage( + eligibility({ + provider: 'gitlab', + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + }) + ) + ).toBe( + 'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.' + ) + }) + it('returns null when the blocked reason should remain non-clickable', () => { expect( resolveBlockedCreateReviewNoticeMessage( diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts index 4bae174619b..1e5c02efb84 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts @@ -36,6 +36,13 @@ export function resolveHostedReviewAuthInstruction(provider: HostedReviewProvide return 'Run gh auth login' } +export function resolveUnavailableCreateReviewLookupNoticeMessage( + provider: HostedReviewProvider +): string { + const copy = localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)) + return `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.` +} + export function resolveBlockedCreateReviewNoticeMessage( eligibility: HostedReviewCreationEligibility | null | undefined ): string | null { @@ -43,6 +50,9 @@ export function resolveBlockedCreateReviewNoticeMessage( return null } const reason = eligibility.blockedReason + if (eligibility.reviewLookupOutcome === 'unavailable' && reason === null) { + return resolveUnavailableCreateReviewLookupNoticeMessage(eligibility.provider) + } if (!canClickBlockedCreateReviewReason(reason)) { return null } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts new file mode 100644 index 00000000000..be66db37a06 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.create-pr-intent.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { + resolveDropdownItems, + type DropdownActionInputs, + type DropdownItem +} from './source-control-dropdown-items' + +// Why: a shared defaults object keeps each case row terse while making the +// "this is the one knob that differs from the baseline" intent obvious. +function inputs(overrides: Partial = {}): DropdownActionInputs { + return { + stagedCount: 0, + hasUnstagedChanges: false, + hasStageableChanges: false, + hasPartiallyStagedChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + isCommitting: false, + isRemoteOperationActive: false, + upstreamStatus: undefined, + ...overrides + } +} + +describe('resolveDropdownItems Create PR intent', () => { + it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.push_create_pr.label).toBe('Push before PR') + expect(byKind.push_create_pr.disabled).toBe(false) + }) + + it.each([ + { + name: 'push', + provider: 'github' as const, + upstreamStatus: { hasUpstream: true as const, ahead: 2, behind: 0 }, + blockedReason: 'needs_push' as const, + expectedTitle: 'Push local commits before creating a pull request' + }, + { + name: 'force push', + provider: 'gitlab' as const, + upstreamStatus: { + hasUpstream: true as const, + upstreamName: 'origin/feature', + ahead: 2, + behind: 1, + behindCommitsArePatchEquivalent: true + }, + blockedReason: 'needs_sync' as const, + expectedTitle: 'Force push with lease before creating a merge request' + } + ])( + 'keeps $name-before-review recovery available when review lookup is unavailable', + ({ provider, upstreamStatus, blockedReason, expectedTitle }) => { + const items = resolveDropdownItems( + inputs({ + branchCommitsAhead: 2, + upstreamStatus, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason, + nextAction: blockedReason === 'needs_push' ? 'push' : 'sync', + reviewLookupOutcome: 'unavailable' + } + }) + ) + const pushCreate = items.find((item): item is DropdownItem => item.kind === 'push_create_pr') + + expect(pushCreate?.disabled).toBe(false) + expect(pushCreate?.title).toBe(expectedTitle) + expect(pushCreate?.hint).toBeUndefined() + } + ) + + it('uses GitLab MR copy for create and push-before-create rows', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.label).toBe('Create MR') + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.push_create_pr.label).toBe('Push before MR') + expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request') + expect(byKind.push_create_pr.disabled).toBe(false) + }) + + it.each(['azure-devops', 'gitea'] as const)( + 'enables push-before-PR recovery for %s review creation', + (provider) => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.label).toBe('Create PR') + expect(byKind.create_pr.hint).toBe('Push first') + expect(byKind.create_pr.disabled).toBe(false) + expect(byKind.push_create_pr.label).toBe('Push before PR') + expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request') + expect(byKind.push_create_pr.disabled).toBe(false) + } + ) + + it.each([ + ['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'], + ['gitea', 'Set ORCA_GITEA_TOKEN in this environment'] + ] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider, + review: null, + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.hint).toBe(hint) + }) + + it('uses GitLab auth copy when MR creation needs authentication', () => { + const items = resolveDropdownItems( + inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: 'auth_required', + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' + } + }) + ) + const byKind = Object.fromEntries( + items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) + ) + expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index d97707dd273..14573a50ec2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -663,125 +663,6 @@ describe('resolveDropdownItems', () => { 'Try a fast-forward pull; git may reject local commits' ) }) - - it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.push_create_pr.label).toBe('Push before PR') - expect(byKind.push_create_pr.disabled).toBe(false) - }) - - it('uses GitLab MR copy for create and push-before-create rows', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.label).toBe('Create MR') - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.push_create_pr.label).toBe('Push before MR') - expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request') - expect(byKind.push_create_pr.disabled).toBe(false) - }) - - it.each(['azure-devops', 'gitea'] as const)( - 'enables push-before-PR recovery for %s review creation', - (provider) => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }, - hostedReviewCreation: { - provider, - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.label).toBe('Create PR') - expect(byKind.create_pr.hint).toBe('Push first') - expect(byKind.create_pr.disabled).toBe(false) - expect(byKind.push_create_pr.label).toBe('Push before PR') - expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request') - expect(byKind.push_create_pr.disabled).toBe(false) - } - ) - - it.each([ - ['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'], - ['gitea', 'Set ORCA_GITEA_TOKEN in this environment'] - ] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider, - review: null, - canCreate: false, - blockedReason: 'auth_required', - nextAction: 'authenticate', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.hint).toBe(hint) - }) - - it('uses GitLab auth copy when MR creation needs authentication', () => { - const items = resolveDropdownItems( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'auth_required', - nextAction: 'authenticate', - reviewLookupOutcome: 'not_found' - } - }) - ) - const byKind = Object.fromEntries( - items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e]) - ) - expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment') - }) }) // Why: PR #8196 — drive the real push-target resolution the component uses so diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts index 13d7bc5dac9..ec4031c7aed 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + buildCreatePrIntentUnavailableEligibility, buildLocalBlockerHostedReviewCreationEligibility, resolveHostedReviewCreationProviderForTarget } from './source-control-hosted-review-creation-eligibility-snapshot' @@ -32,7 +33,7 @@ describe('resolveHostedReviewCreationProviderForTarget', () => { }) describe('buildLocalBlockerHostedReviewCreationEligibility', () => { - it('reports dirty without offering create intent when the review lookup failed', () => { + it('reports dirty with unavailable lookup while still allowing prepare-only Create PR intent', () => { const eligibility = buildLocalBlockerHostedReviewCreationEligibility('github', { ...featureBranch, hasUncommittedChanges: true, @@ -45,7 +46,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { nextAction: 'commit', reviewLookupOutcome: 'unavailable' }) - // Why: branch guidance can remain specific, but a failed lookup cannot authorize creation. + // Why: branch prep is safe without lookup authority; final create stays fail-closed in main. expect( resolveCreatePrIntentEligibility({ stagedCount: 1, @@ -56,7 +57,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { hostedReviewCreation: eligibility, branchCommitsAhead: 0 }) - ).toEqual({ eligible: false, kind: null }) + ).toEqual({ eligible: true, kind: 'dirty' }) }) it('prefers dirty over no_upstream when both apply, matching main-process ordering', () => { @@ -122,6 +123,19 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ).toBeNull() }) + it('returns null when the default branch is unknown', () => { + expect( + buildLocalBlockerHostedReviewCreationEligibility('github', { + branch: 'main', + baseRef: null, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) + it('returns null for a detached HEAD', () => { expect( buildLocalBlockerHostedReviewCreationEligibility('github', { @@ -185,3 +199,72 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ).toBeNull() }) }) + +describe('buildCreatePrIntentUnavailableEligibility', () => { + it('keeps a rejected click-time probe moving through push and final preflight', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + ...featureBranch, + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).toMatchObject({ + blockedReason: 'needs_push', + nextAction: 'push', + reviewLookupOutcome: 'unavailable' + }) + expect( + buildCreatePrIntentUnavailableEligibility('github', { + ...featureBranch, + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toMatchObject({ + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + }) + }) + + it('never synthesizes final intent eligibility for the default branch', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + branch: 'main', + baseRef: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 1, + behind: 0 + }) + ).toBeNull() + }) + + it('never synthesizes intent eligibility when the default branch is unknown', () => { + expect( + buildCreatePrIntentUnavailableEligibility('github', { + branch: 'main', + baseRef: null, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) + + it('never synthesizes intent eligibility for an unsupported remote provider', () => { + expect( + buildCreatePrIntentUnavailableEligibility('bitbucket', { + ...featureBranch, + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts index 0857f94f6a0..b964323830e 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts @@ -5,6 +5,33 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' +type UnavailableHostedReviewStatus = { + branch: string | null | undefined + baseRef: string | null | undefined + hasUncommittedChanges: boolean + hasUpstream: boolean | undefined + ahead: number | undefined + behind: number | undefined +} + +function resolveUnavailableHostedReviewBranch( + provider: HostedReviewProvider, + status: UnavailableHostedReviewStatus +): string | null { + const branch = status.branch?.trim() ?? '' + const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim() + if ( + branch === '' || + branch === 'HEAD' || + baseBranch === '' || + !supportsHostedReviewCreation(provider) || + branch.toLowerCase() === baseBranch.toLowerCase() + ) { + return null + } + return branch +} + export function buildLoadingHostedReviewCreationEligibility( provider: HostedReviewProvider ): HostedReviewCreationEligibility { @@ -41,44 +68,31 @@ export function resolveHostedReviewCreationProviderForTarget( * times out, so the UI can show branch guidance without treating the failed * lookup as authority to create a review. * - * Mirrors the main process's own lookup-failure fallback exactly — both its - * `canReturnLocalBlocker` guard and its blocker ordering - * (`src/main/source-control/hosted-review-creation.ts`). The guard is - * load-bearing: without it a failed probe on the default branch or a detached - * HEAD would synthesize `dirty`/`commit` and surface an *enabled* Create PR - * that commits onto the base branch, where the real probe returns - * `default_branch`/`detached_head` (disabled). Returns null when no local - * blocker is determinable (unknown upstream, ahead-only, fully synced) so the - * caller can surface the retryable state — matching main, which won't offer a - * push it can't first auth-check. + * Blocker ordering matches main (`dirty` → `no_upstream` → `needs_sync`). The + * branch/base guard is load-bearing and intentionally stricter than main when + * base is unknown: without a known base, a failed probe must not synthesize + * `dirty`/`commit` on what might be the default branch (main would return + * `default_branch`/`detached_head` and keep Create disabled). Returns null + * when no local blocker is determinable (unknown base, unknown upstream, + * ahead-only, fully synced) so the caller can surface the retryable state — + * matching main, which won't offer a push it can't first auth-check. */ export function buildLocalBlockerHostedReviewCreationEligibility( provider: HostedReviewProvider, - status: { - branch: string | null | undefined - baseRef: string | null | undefined - hasUncommittedChanges: boolean - hasUpstream: boolean | undefined - ahead: number | undefined - behind: number | undefined - } + status: UnavailableHostedReviewStatus ): HostedReviewCreationEligibility | null { - const branch = status.branch?.trim() ?? '' - const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim() - const canReturnLocalBlocker = - branch !== '' && - branch !== 'HEAD' && - supportsHostedReviewCreation(provider) && - (baseBranch === '' || branch.toLowerCase() !== baseBranch.toLowerCase()) && - (status.hasUncommittedChanges || status.hasUpstream !== true || (status.behind ?? 0) > 0) - if (!canReturnLocalBlocker) { + const branch = resolveUnavailableHostedReviewBranch(provider, status) + if ( + !branch || + (!status.hasUncommittedChanges && status.hasUpstream === true && (status.behind ?? 0) === 0) + ) { return null } const base = { provider, review: null, canCreate: false as const, - defaultBaseRef: null, + defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(), head: branch, // Why: local Git blockers cannot prove that a hosted review does not exist. reviewLookupOutcome: 'unavailable' as const @@ -96,3 +110,29 @@ export function buildLocalBlockerHostedReviewCreationEligibility( } return null } + +export function buildCreatePrIntentUnavailableEligibility( + provider: HostedReviewProvider, + status: UnavailableHostedReviewStatus +): HostedReviewCreationEligibility | null { + const localBlocker = buildLocalBlockerHostedReviewCreationEligibility(provider, status) + if (localBlocker) { + return localBlocker + } + const branch = resolveUnavailableHostedReviewBranch(provider, status) + if (!branch || status.hasUpstream !== true) { + return null + } + const base = { + provider, + review: null, + canCreate: false as const, + defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(), + head: branch, + reviewLookupOutcome: 'unavailable' as const + } + if ((status.ahead ?? 0) > 0) { + return { ...base, blockedReason: 'needs_push', nextAction: 'push' } + } + return { ...base, blockedReason: null, nextAction: null } +} diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts index c56892b71ec..5c526e047b6 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -177,6 +177,32 @@ describe('resolvePrimaryAction Create PR intent', () => { }) }) + it('returns Create PR intent for a dirty tree when review lookup is unavailable', () => { + const input = inputs({ + hasUnstagedChanges: true, + hasStageableChanges: true, + upstreamStatus: upstreamInSync, + hostedReviewCreation: { + provider: 'github', + review: null, + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + defaultBaseRef: 'main', + reviewLookupOutcome: 'unavailable' + } + }) + + expect(resolvePrimaryAction(input)).toMatchObject({ + kind: 'create_pr_intent', + disabled: false + }) + expect(resolveCreatePrHeaderAction(input)).toMatchObject({ + kind: 'create_pr_intent', + disabled: false + }) + }) + it('returns Create PR intent for staged changes without a message so the flow can request one', () => { const input = inputs({ stagedCount: 1, diff --git a/src/shared/source-control-create-review-intent.test.ts b/src/shared/source-control-create-review-intent.test.ts new file mode 100644 index 00000000000..4763117a5be --- /dev/null +++ b/src/shared/source-control-create-review-intent.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { + resolveCreateReviewIntentEligibility, + type CreateReviewIntentKind +} from './source-control-create-review-intent' +import type { HostedReviewCreationBlockedReason } from './hosted-review' +import type { GitUpstreamStatus } from './git-status-types' + +function unavailableEligibility(blockedReason: HostedReviewCreationBlockedReason) { + return { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason, + nextAction: null, + defaultBaseRef: 'main', + reviewLookupOutcome: 'unavailable' as const + } +} + +describe('resolveCreateReviewIntentEligibility', () => { + it('rejects unavailable eligibility when the default branch is unknown', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + defaultBaseRef: null + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('rejects unavailable eligibility when the default branch is blank', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + defaultBaseRef: ' ' + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('stays ineligible when no local blocker remains under unavailable lookup', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 0, + hasStageableChanges: false, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + ...unavailableEligibility('dirty'), + blockedReason: null + } + }) + ).toEqual({ eligible: false, kind: null }) + }) + + it('keeps dirty local preparation eligible when review lookup is unavailable', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 0, + hasStageableChanges: true, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: unavailableEligibility('dirty') + }) + ).toEqual({ eligible: true, kind: 'dirty' }) + }) + + it('still requires a message before committing staged changes without lookup authority', () => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount: 1, + hasStageableChanges: false, + hasMessage: false, + hasUnresolvedConflicts: false, + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: unavailableEligibility('dirty') + }) + ).toEqual({ eligible: true, kind: 'message_required' }) + }) + + it.each<{ + blockedReason: HostedReviewCreationBlockedReason + blockedKind: CreateReviewIntentKind + stagedCount?: number + hasStageableChanges?: boolean + branchCommitsAhead?: number + upstreamStatus?: GitUpstreamStatus + }>([ + { + blockedReason: 'no_upstream', + blockedKind: 'no_upstream', + branchCommitsAhead: 1, + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + }, + { + blockedReason: 'needs_push', + blockedKind: 'needs_push', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 1, + behind: 0 + } + }, + { + blockedReason: 'needs_sync', + blockedKind: 'needs_sync', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + }, + { + blockedReason: 'needs_sync', + blockedKind: 'force_push', + branchCommitsAhead: 1, + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 2, + behind: 1, + behindCommitsArePatchEquivalent: true + } + } + ])( + 'keeps recoverable $blockedKind preparation eligible when review lookup is unavailable', + ({ + blockedReason, + blockedKind, + stagedCount = 0, + hasStageableChanges = false, + branchCommitsAhead, + upstreamStatus + }) => { + expect( + resolveCreateReviewIntentEligibility({ + stagedCount, + hasStageableChanges, + hasMessage: true, + hasUnresolvedConflicts: false, + upstreamStatus, + hostedReviewCreation: unavailableEligibility(blockedReason), + branchCommitsAhead + }) + ).toEqual({ eligible: true, kind: blockedKind }) + } + ) +}) diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts index 5d1f517280b..b7266708b25 100644 --- a/src/shared/source-control-create-review-intent.ts +++ b/src/shared/source-control-create-review-intent.ts @@ -41,16 +41,20 @@ export function resolveCreateReviewIntentEligibility({ !hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate || - // Fail closed when the existing-review lookup could not prove there is no - // review: a local blocker (e.g. needs_push) returned after a failed lookup - // must not offer a Create PR intent that would push under a false promise — - // the main preflight would refuse the create anyway (invariant 8). - hostedReviewCreation.reviewLookupOutcome === 'unavailable' || !supportsHostedReviewCreation(hostedReviewCreation.provider) ) { return { eligible: false, kind: null } } + if ( + hostedReviewCreation.reviewLookupOutcome === 'unavailable' && + !hostedReviewCreation.defaultBaseRef?.trim() + ) { + return { eligible: false, kind: null } + } + + // Why: safe branch preparation can continue without lookup authority; the + // main create preflight still fails closed before creating a duplicate. if (hostedReviewCreation.blockedReason === 'dirty') { if (stagedCount > 0 && !hasMessage) { return { eligible: true, kind: 'message_required' } diff --git a/tests/e2e/source-control-create-pr-intent-switch.spec.ts b/tests/e2e/source-control-create-pr-intent-switch.spec.ts new file mode 100644 index 00000000000..816cf396d3c --- /dev/null +++ b/tests/e2e/source-control-create-pr-intent-switch.spec.ts @@ -0,0 +1,344 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + createStagedCommitMessageChange, + openSourceControl, + seedCreatePrComposer +} from './helpers/source-control-ai-generation' + +async function writeEvidence( + testInfo: TestInfo, + screenshotDir: string, + filename: string, + evidence: unknown +): Promise { + const evidencePath = path.join(screenshotDir, filename) + writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`) + await testInfo.attach(filename, { + path: evidencePath, + contentType: 'application/json' + }) +} + +function removeOriginRemoteIfPresent(cwd: string): void { + // Why: check presence instead of swallowing errors, so real Git failures still surface. + const remotes = execFileSync('git', ['remote'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }) + .split('\n') + .map((line) => line.trim()) + if (!remotes.includes('origin')) { + return + } + execFileSync('git', ['remote', 'remove', 'origin'], { cwd, stdio: 'pipe' }) +} + +test.describe('Source Control Create PR intent worktree switching', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps Create PR intent running after switching worktrees', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } = + await seedCreatePrComposer(orcaPage) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `create-pr-intent-switch-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + + await orcaPage.evaluate( + ({ prWorktreeId, primaryBranch }) => { + const store = + window.__store ?? + (() => { + throw new Error('window.__store is not available') + })() + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === prWorktreeId) + if (!worktree) { + throw new Error('Create PR intent worktree not found') + } + const repo = state.repos.find((entry) => entry.id === worktree.repoId) + if (!repo) { + throw new Error('Create PR intent repo not found') + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + + type CreatePrIntentHostedReviewCall = { + repoPath: string + input: { + base?: string + head?: string + worktreePath?: string + } + } + const testWindow = window as unknown as { + __createPRIntentPayloads: CreatePrIntentHostedReviewCall[] + __createPRIntentPushStarted: boolean + __createPRIntentPushFinished: boolean + } + testWindow.__createPRIntentPayloads = [] + testWindow.__createPRIntentPushStarted = false + testWindow.__createPRIntentPushFinished = false + store.setState((current) => ({ + getHostedReviewCreationEligibility: async () => { + // Why: eligibility stays blocked until the delayed push completes, + // so this test exercises navigation during an in-flight intent run. + if (!testWindow.__createPRIntentPushFinished) { + return { + provider: 'github' as const, + review: null, + canCreate: false, + blockedReason: 'needs_push' as const, + nextAction: 'push' as const, + defaultBaseRef: primaryBranch, + head: branch + } + } + return { + provider: 'github' as const, + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: primaryBranch, + title: 'Create PR intent after switching worktrees', + body: 'The intent flow should continue after navigation.', + head: branch + } + }, + fetchHostedReviewForBranch: async () => null, + fetchPRForBranch: async () => null, + pushBranch: async (worktreeId) => { + if (worktreeId !== prWorktreeId) { + throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) + } + testWindow.__createPRIntentPushStarted = true + await new Promise((resolve) => setTimeout(resolve, 1500)) + testWindow.__createPRIntentPushFinished = true + }, + createHostedReview: async (repoPath, input) => { + testWindow.__createPRIntentPayloads.push({ repoPath, input }) + return { + ok: true as const, + number: 74, + url: 'https://github.com/acme/orca/pull/74' + } + }, + gitStatusByWorktree: { + ...current.gitStatusByWorktree, + [worktree.id]: [] + }, + remoteStatusesByWorktree: { + ...current.remoteStatusesByWorktree, + [worktree.id]: { + hasUpstream: true, + upstreamName: `origin/${branch}`, + ahead: 1, + behind: 0 + } + } + })) + }, + { prWorktreeId, primaryBranch } + ) + + await openSourceControl(orcaPage, prWorktreeId) + const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() + await expect(createPr).toBeVisible({ timeout: 10_000 }) + await expect(createPr).toBeEnabled() + await createPr.click() + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __createPRIntentPushStarted: boolean }) + .__createPRIntentPushStarted + ), + { timeout: 10_000 } + ) + .toBe(true) + await openSourceControl(orcaPage, primaryWorktreeId) + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __createPRIntentPayloads: unknown[] }) + .__createPRIntentPayloads.length + ), + { timeout: 10_000 } + ) + .toBe(1) + + const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + return { + activeWorktreeId: state?.activeWorktreeId, + rightSidebarTab: state?.rightSidebarTab + } + }) + expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId) + expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control') + + await openSourceControl(orcaPage, prWorktreeId) + const payloads = await orcaPage.evaluate( + () => + ( + window as unknown as { + __createPRIntentPayloads: { + repoPath: string + input: { base?: string; head?: string; worktreePath?: string } + }[] + } + ).__createPRIntentPayloads + ) + expect(payloads).toHaveLength(1) + expect(payloads[0]).toMatchObject({ + input: { + base: primaryBranch, + head: 'e2e-secondary', + worktreePath: prWorktreePath + } + }) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png') + }) + await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', { + expectedOriginalWorktreeId: prWorktreeId, + expectedOtherWorktreeId: primaryWorktreeId, + completedWhileSwitched: completedWhileSwitchedEvidence, + payloads + }) + }) + + test('carries unavailable dirty intent through push to the final create preflight', async ({ + orcaPage, + registerPostElectronShutdownCleanup + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const { prWorktreeId, prWorktreePath } = await seedCreatePrComposer(orcaPage) + const remoteRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-create-pr-remote-')) + const remotePath = path.join(remoteRoot, 'origin.git') + execFileSync('git', ['init', '--bare', remotePath]) + // Why: the seeded worktree may already define origin, so make the add idempotent. + removeOriginRemoteIfPresent(prWorktreePath) + execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: prWorktreePath }) + registerPostElectronShutdownCleanup(async () => { + removeOriginRemoteIfPresent(prWorktreePath) + rmSync(remoteRoot, { recursive: true, force: true }) + }) + createStagedCommitMessageChange(prWorktreePath) + + const finalCreateError = 'Unavailable lookup intent reached final create preflight' + await orcaPage.evaluate( + ({ prWorktreeId, finalCreateError }) => { + const store = + window.__store ?? + (() => { + throw new Error('window.__store is not available') + })() + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === prWorktreeId) + if (!worktree) { + throw new Error('Create PR intent worktree not found') + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const pushBranchAction = state.pushBranch + const testWindow = window as unknown as { + __unavailableIntentPushFinished: boolean + } + testWindow.__unavailableIntentPushFinished = false + + store.setState((current) => ({ + repos: current.repos.map((repo) => + repo.id === worktree.repoId + ? { + ...repo, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/orca', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/orca.git' + } + } + : repo + ), + remoteStatusesByWorktree: { + ...current.remoteStatusesByWorktree, + [prWorktreeId]: { + hasUpstream: true, + upstreamName: `origin/${branch}`, + ahead: 1, + behind: 0 + } + }, + getHostedReviewCreationEligibility: async () => { + throw new Error('Hosted review eligibility timed out') + }, + pushBranch: async (...args: Parameters) => { + const [worktreeId] = args + if (worktreeId !== prWorktreeId) { + throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) + } + await pushBranchAction(...args) + testWindow.__unavailableIntentPushFinished = true + }, + createHostedReview: async () => ({ + ok: false as const, + code: 'validation' as const, + error: finalCreateError + }) + })) + }, + { prWorktreeId, finalCreateError } + ) + + await openSourceControl(orcaPage, prWorktreeId) + await expect(orcaPage.getByText('e2e-commit-message-generation.txt')).toBeVisible({ + timeout: 10_000 + }) + await orcaPage + .getByRole('textbox', { name: 'Commit message' }) + .fill('Exercise unavailable Create PR intent') + const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() + await expect(createPr).toBeEnabled() + await createPr.click() + + await expect + .poll( + () => + orcaPage.evaluate( + () => + (window as unknown as { __unavailableIntentPushFinished: boolean }) + .__unavailableIntentPushFinished + ), + { timeout: 10_000 } + ) + .toBe(true) + await expect(orcaPage.getByText(finalCreateError)).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts index 2cf0a3e9bb7..283b47416eb 100644 --- a/tests/e2e/source-control-pr-generation-switch.spec.ts +++ b/tests/e2e/source-control-pr-generation-switch.spec.ts @@ -314,196 +314,6 @@ test.describe('Source Control AI PR generation worktree switching', () => { }) }) - test('keeps Create PR intent running after switching worktrees', async ({ - orcaPage - }, testInfo) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } = - await seedCreatePrComposer(orcaPage) - - const screenshotDir = path.join( - process.cwd(), - 'validation-screenshots', - `create-pr-intent-switch-${Date.now()}` - ) - mkdirSync(screenshotDir, { recursive: true }) - await testInfo.attach('validation-screenshot-dir', { - body: screenshotDir, - contentType: 'text/plain' - }) - - await orcaPage.evaluate( - ({ prWorktreeId, primaryBranch }) => { - const store = - window.__store ?? - (() => { - throw new Error('window.__store is not available') - })() - const state = store.getState() - const worktree = Object.values(state.worktreesByRepo) - .flat() - .find((entry) => entry.id === prWorktreeId) - if (!worktree) { - throw new Error('Create PR intent worktree not found') - } - const repo = state.repos.find((entry) => entry.id === worktree.repoId) - if (!repo) { - throw new Error('Create PR intent repo not found') - } - const branch = worktree.branch.replace(/^refs\/heads\//, '') - - type CreatePrIntentHostedReviewCall = { - repoPath: string - input: { - base?: string - head?: string - worktreePath?: string - } - } - const testWindow = window as unknown as { - __createPRIntentPayloads: CreatePrIntentHostedReviewCall[] - __createPRIntentPushStarted: boolean - __createPRIntentPushFinished: boolean - } - testWindow.__createPRIntentPayloads = [] - testWindow.__createPRIntentPushStarted = false - testWindow.__createPRIntentPushFinished = false - store.setState((current) => ({ - getHostedReviewCreationEligibility: async () => { - // Why: eligibility stays blocked until the delayed push completes, - // so this test exercises navigation during an in-flight intent run. - if (!testWindow.__createPRIntentPushFinished) { - return { - provider: 'github' as const, - review: null, - canCreate: false, - blockedReason: 'needs_push' as const, - nextAction: 'push' as const, - defaultBaseRef: primaryBranch, - head: branch - } - } - return { - provider: 'github' as const, - review: null, - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: primaryBranch, - title: 'Create PR intent after switching worktrees', - body: 'The intent flow should continue after navigation.', - head: branch - } - }, - fetchHostedReviewForBranch: async () => null, - fetchPRForBranch: async () => null, - pushBranch: async (worktreeId) => { - if (worktreeId !== prWorktreeId) { - throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`) - } - testWindow.__createPRIntentPushStarted = true - await new Promise((resolve) => setTimeout(resolve, 1500)) - testWindow.__createPRIntentPushFinished = true - }, - createHostedReview: async (repoPath, input) => { - testWindow.__createPRIntentPayloads.push({ repoPath, input }) - return { - ok: true as const, - number: 74, - url: 'https://github.com/acme/orca/pull/74' - } - }, - gitStatusByWorktree: { - ...current.gitStatusByWorktree, - [worktree.id]: [] - }, - remoteStatusesByWorktree: { - ...current.remoteStatusesByWorktree, - [worktree.id]: { - hasUpstream: true, - upstreamName: `origin/${branch}`, - ahead: 1, - behind: 0 - } - } - })) - }, - { prWorktreeId, primaryBranch } - ) - - await openSourceControl(orcaPage, prWorktreeId) - const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first() - await expect(createPr).toBeVisible({ timeout: 10_000 }) - await expect(createPr).toBeEnabled() - await createPr.click() - - await expect - .poll( - () => - orcaPage.evaluate( - () => - (window as unknown as { __createPRIntentPushStarted: boolean }) - .__createPRIntentPushStarted - ), - { timeout: 10_000 } - ) - .toBe(true) - await openSourceControl(orcaPage, primaryWorktreeId) - - await expect - .poll( - () => - orcaPage.evaluate( - () => - (window as unknown as { __createPRIntentPayloads: unknown[] }) - .__createPRIntentPayloads.length - ), - { timeout: 10_000 } - ) - .toBe(1) - - const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => { - const state = window.__store?.getState() - return { - activeWorktreeId: state?.activeWorktreeId, - rightSidebarTab: state?.rightSidebarTab - } - }) - expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId) - expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control') - - await openSourceControl(orcaPage, prWorktreeId) - const payloads = await orcaPage.evaluate( - () => - ( - window as unknown as { - __createPRIntentPayloads: { - repoPath: string - input: { base?: string; head?: string; worktreePath?: string } - }[] - } - ).__createPRIntentPayloads - ) - expect(payloads).toHaveLength(1) - expect(payloads[0]).toMatchObject({ - input: { - base: primaryBranch, - head: 'e2e-secondary', - worktreePath: prWorktreePath - } - }) - await orcaPage.screenshot({ - path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png') - }) - await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', { - expectedOriginalWorktreeId: prWorktreeId, - expectedOtherWorktreeId: primaryWorktreeId, - completedWhileSwitched: completedWhileSwitchedEvidence, - payloads - }) - }) - test('hydrates pending PR generation after Source Control remounts', async ({ orcaPage }, testInfo) => { From a2d0e7714334f5f921be807acb1a24aa05b1cda1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:49:20 +0000 Subject: [PATCH 044/123] release: v1.4.163-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c37bc18efb..1a05084eabc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.163-rc.0", + "version": "1.4.163-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 73e243705f508ec1bf422ca41325298f55b5b1aa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:31:00 -0700 Subject: [PATCH 045/123] fix: stabilize new worktree task source tabs (#11701) --- .../SmartWorkspaceNameField-source-boundaries.test.ts | 4 ++++ .../components/new-workspace/SmartWorkspaceNameField.tsx | 9 ++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index bfe556addf4..414b438bad7 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -113,4 +113,8 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(FIELD_SOURCE).toContain('onPointerDown={() => {') expect(FIELD_SOURCE).toContain('markSourcePopoverUserEngaged()') }) + + it('confines source-mode overflow to the source strip', () => { + expect(FIELD_SOURCE).toContain('overflow-x-auto overflow-y-hidden px-0 scrollbar-sleek') + }) }) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index a0168f90093..ead339690ea 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -394,11 +394,10 @@ export default function SmartWorkspaceNameField({ link: NonNullable> matchingRepo: RepoOption | null } | null>(null) - // Why: Jira status is read lazily — mounting the composer must not cost an IPC/RPC round-trip, - // so only reach for it once the user opens the source picker, picks Jira, or pastes an issue URL. + // Why: read Jira status when the composer mounts so an already-configured source is available + // before users start typing, without showing Jira for hosts where it is not configured. const jiraConnection = useJiraSourceConnection({ - enabled: - !disabled && !textOnly && (open || mode === 'jira' || isBlockingJiraUrlIntent(mode, value)), + enabled: !disabled && !textOnly && jiraSourceContext !== null, sourceContext: jiraSourceContext }) const jiraConnectionStatus = jiraConnection.status @@ -1624,7 +1623,7 @@ export default function SmartWorkspaceNameField({ { // Why: Radix Tabs roving focus re-applies tabindex=0 to the active trigger (races React commits), so forward Tab to the input. const previous = event.relatedTarget as HTMLElement | null From e1092291ae4e90d18b1d896e9f8e8c3ef056480f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:41:51 -0700 Subject: [PATCH 046/123] fix(settings): keep copied skill commands bare for POSIX-family Windows shells (#11599) The cmd.exe npx preflight added in #10453 silently no-ops when pasted into Git Bash: MSYS rewrites the leading /d /s /c switches into drive paths, so cmd.exe starts an interactive session and never runs the payload. Skip the wrapper when the configured Windows shell resolves to the posix family, matching the shell the copied command actually lands in. --- .../settings/CliSkillRuntimeSetup.test.tsx | 49 +++++++++++++++++++ .../settings/CliSkillRuntimeSetup.tsx | 12 +++++ 2 files changed, 61 insertions(+) diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx index 6150bd808eb..269074ff895 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.test.tsx @@ -243,6 +243,55 @@ describe('CliSkillRuntimeSetup runtime helpers', () => { } }) + it('skips the Windows preflight when the configured Windows shell is POSIX-family', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const windowsHost = { runtime: 'host', label: 'Windows' } as const + const previous = useAppStore.getState() + + try { + // MSYS rewrites cmd.exe's leading /d /s /c switches into drive paths, so + // the copied command must stay bare for a Git Bash / wsl.exe paste target. + for (const terminalWindowsShell of ['git-bash', 'C:\\Program Files\\Git\\bin\\bash.exe']) { + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + installCommand + ) + } + + // cmd-family shells still need the preflight wrapper. + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'cmd.exe' } + }) + expect(buildSkillCommandForRuntime(installCommand, windowsHost, 'win32')).toBe( + `${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"` + ) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const previous = useAppStore.getState() + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'git-bash' } + }) + + try { + expect( + buildSkillCommandForRuntime( + 'npx skills update orchestration --global', + { runtime: 'host', label: 'Windows' }, + 'win32' + ) + ).toBe(installCommand) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + it('does not wrap unrelated Windows host commands', () => { expect( buildSkillCommandForRuntime( diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index bee39049fc5..005b5dfc5c6 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -8,6 +8,7 @@ import { quotePowerShellNativeArgument } from '../../../../shared/powershell-native-argument' import { buildWslLoginShellCommand } from '../../../../shared/wsl-login-shell-command' +import { resolveWindowsShellStartupFamily } from '../../../../shared/windows-terminal-shell' import { getProjectAgentSkillTerminalShellOverride } from '@/lib/project-skill-runtime' import { useAppStore } from '@/store' import { buildAgentFeatureSkillInstallCommand } from '../../../../shared/agent-feature-install-commands' @@ -136,6 +137,10 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( // Why: skill setup terminals spawn on the focused runtime environment, so a // Windows client must not hand a cmd.exe command to a remote host. isRemoteRuntimeEnvironmentFocused() || + // Why: the copied command lands in the user's configured shell, and MSYS + // shells rewrite cmd.exe's leading /d /s /c switches into drive paths, + // starting an interactive cmd session instead of running the payload. + isPosixFamilyWindowsShellConfigured() || !/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand) ) { return command @@ -149,6 +154,13 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( return `cmd.exe /d /s /c "where.exe npx >nul 2>nul & if errorlevel 1 (${missingNpxGuidance}) else (${trimmedCommand})"` } +function isPosixFamilyWindowsShellConfigured(): boolean { + return ( + resolveWindowsShellStartupFamily(useAppStore.getState().settings?.terminalWindowsShell) === + 'posix' + ) +} + function isRemoteRuntimeEnvironmentFocused(): boolean { // Why: the terminal router also weighs how many environments are saved, but // that slice has no subscriber here. Read only the focused id, which every From 0ffdb79d343be82f17761d5f8a5ee90093393328 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:49 -0700 Subject: [PATCH 047/123] fix(skills): surface failed setup commands and retry them (#11630) * fix(skills): surface failed setup commands and retry them * fix(skills): preserve failed setup diagnostics * fix(skills): retire failed terminal before retry --- ...eCommandTerminal.command-finished.test.tsx | 117 +++++++++++ .../OnboardingInlineCommandTerminal.tsx | 27 ++- .../settings/AgentSkillSetupFailureNotice.tsx | 18 ++ .../settings/AgentSkillSetupPanel.test.tsx | 188 +++++++++++++++++- .../settings/AgentSkillSetupPanel.tsx | 177 +++++++++-------- .../settings/agent-skill-setup-panel-props.ts | 43 ++++ .../parked-terminal-command-status.test.ts | 2 +- .../parked-terminal-command-status.ts | 4 +- .../terminal-pane/pty-connection.ts | 4 +- .../hooks/terminal-command-finished-event.ts | 12 +- src/renderer/src/i18n/locales/en.json | 5 +- 11 files changed, 503 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx create mode 100644 src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx create mode 100644 src/renderer/src/components/settings/agent-skill-setup-panel-props.ts diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx new file mode 100644 index 00000000000..1b55391dec4 --- /dev/null +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.command-finished.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' + +const mocks = vi.hoisted(() => ({ + createTab: vi.fn(() => ({ id: 'tab-1' })), + closeTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), + setTabCustomTitle: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + createTab: mocks.createTab, + closeTab: mocks.closeTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, + setTabCustomTitle: mocks.setTabCustomTitle + }) +})) + +vi.mock('@/components/terminal-pane/TerminalPane', () => ({ + default: () =>
    +})) + +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ + focusTerminalTabSurface: vi.fn() +})) + +function dispatchCommandFinished(worktreeId: string, exitCode: number | null): void { + window.dispatchEvent( + new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { + detail: { worktreeId, exitCode } + }) + ) +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('OnboardingInlineCommandTerminal command-finished forwarding', () => { + beforeEach(() => { + mocks.createTab.mockClear() + mocks.closeTab.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + app: { + getFloatingTerminalCwd: vi.fn(async () => '/tmp') + } + } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('forwards exit codes only for its own branded worktree id', async () => { + const onCommandFinished = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + ) + }) + await act(async () => {}) + + await act(async () => { + dispatchCommandFinished('some-other-worktree', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + // Why unbranded-miss matters: pty-connection dispatches the BRANDED id; + // matching the raw panel id would silently never fire in production. + await act(async () => { + dispatchCommandFinished('settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).not.toHaveBeenCalled() + + await act(async () => { + dispatchCommandFinished('ephemeral-setup-terminal:settings-orchestration-skill-terminal', 1) + }) + expect(onCommandFinished).toHaveBeenCalledWith(1) + + await act(async () => { + dispatchCommandFinished( + 'ephemeral-setup-terminal:settings-orchestration-skill-terminal', + null + ) + }) + expect(onCommandFinished).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index a0b8e894b47..0a7f0ae7a8f 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -3,6 +3,10 @@ import { Loader2 } from 'lucide-react' import TerminalPane from '@/components/terminal-pane/TerminalPane' import { PASTE_TERMINAL_TEXT_EVENT, type PasteTerminalTextDetail } from '@/constants/terminal' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { + ORCA_TERMINAL_COMMAND_FINISHED_EVENT, + type TerminalCommandFinishedEventDetail +} from '@/hooks/terminal-command-finished-event' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { brandEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' @@ -29,6 +33,8 @@ type OnboardingInlineCommandTerminalProps = { onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void onTerminalExit?: () => void + // OSC 133;D reports the command outcome while the shell remains alive. + onCommandFinished?: (bestEffortExitCode: number | null) => void } /** @@ -48,7 +54,8 @@ export function OnboardingInlineCommandTerminal({ shellOverride, onOpened, onInteracted, - onTerminalExit + onTerminalExit, + onCommandFinished }: OnboardingInlineCommandTerminalProps): React.JSX.Element { // Why: brand the id so a remote runtime scopes this ephemeral terminal to the // floating terminal instead of rejecting the synthetic id. @@ -80,6 +87,24 @@ export function OnboardingInlineCommandTerminal({ onOpened?.() }, [onOpened]) + // Why: the branded id isolates command outcomes to this inline terminal. + useEffect(() => { + if (!onCommandFinished) { + return + } + const handleCommandFinished = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.worktreeId !== worktreeId) { + return + } + onCommandFinished(detail.exitCode) + } + window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + return () => { + window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished) + } + }, [onCommandFinished, worktreeId]) + useEffect(() => { let cancelled = false void window.api.app.getFloatingTerminalCwd({ path: '~' }).then((nextCwd) => { diff --git a/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx new file mode 100644 index 00000000000..eba25d637a4 --- /dev/null +++ b/src/renderer/src/components/settings/AgentSkillSetupFailureNotice.tsx @@ -0,0 +1,18 @@ +import { translate } from '@/i18n/i18n' + +export function AgentSkillSetupFailureNotice(props: { + exitCode: number | null +}): React.JSX.Element | null { + if (props.exitCode === null) { + return null + } + return ( +

    + {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupCommandFailed', + 'The setup command exited with code {{value0}}. This error will clear after a successful retry.', + { value0: props.exitCode } + )} +

    + ) +} diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 533144f8bdf..ac44ed3285c 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, type ComponentProps } from 'react' +import { act, useState, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -12,11 +12,17 @@ const UPDATE_COMMAND = 'npx skills update orca-cli --global' const mocks = vi.hoisted(() => ({ clipboardWrite: vi.fn(), - terminalProps: [] as { command: string; description: string }[], + terminalProps: [] as { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }[], toastError: vi.fn(), toastSuccess: vi.fn(), skillsChanged: vi.fn(), - skillsRefreshed: vi.fn() + skillsRefreshed: vi.fn(), + terminalInstanceCount: 0 })) vi.mock('sonner', () => ({ @@ -32,13 +38,23 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ })) vi.mock('../onboarding/OnboardingInlineCommandTerminal', () => ({ - OnboardingInlineCommandTerminal: (props: { command: string; description: string }) => { + OnboardingInlineCommandTerminal: (props: { + command: string + description: string + onTerminalExit?: () => void + onCommandFinished?: (bestEffortExitCode: number | null) => void + }) => { + const [instance] = useState(() => { + mocks.terminalInstanceCount += 1 + return mocks.terminalInstanceCount + }) mocks.terminalProps.push(props) return (
    {props.command}
    @@ -139,6 +155,7 @@ describe('AgentSkillSetupPanel', () => { mocks.toastSuccess.mockReset() mocks.skillsChanged.mockReset() mocks.skillsRefreshed.mockReset() + mocks.terminalInstanceCount = 0 Object.defineProperty(window, 'api', { configurable: true, value: { @@ -354,4 +371,167 @@ describe('AgentSkillSetupPanel', () => { expect(container?.textContent).toContain(INSTALL_COMMAND) expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) }) + + it('keeps a failed setup command visible with durable recovery', async () => { + const onRecheck = vi.fn() + await renderInteractivePanel({ onRecheck }) + await clickButton('Install') + onRecheck.mockClear() + + await act(async () => { + const onCommandFinished = mocks.terminalProps.at(-1)?.onCommandFinished + onCommandFinished?.(1) + onCommandFinished?.(0) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + expect(container?.textContent).toContain('Setup failed') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).not.toBeNull() + expect(findButton('Retry').disabled).toBe(false) + expect(onRecheck).toHaveBeenCalledTimes(1) + }) + + it('clears the failure notice when a later command succeeds', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(container?.textContent).not.toContain('exited with code') + }) + + it('keeps the failure verdict when a command finishes without an exit code', async () => { + await renderInteractivePanel() + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(null) + }) + + expect(container?.textContent).toContain( + 'The setup command exited with code 1. This error will clear after a successful retry.' + ) + }) + + it('retries a failed command in a fresh interactive terminal', async () => { + let finishRetryPreflight: (() => void) | null = null + const retryPreflight = new Promise((resolve) => { + finishRetryPreflight = resolve + }) + let preflightCount = 0 + await renderInteractivePanel({ + onBeforeOpenTerminal: () => { + preflightCount += 1 + return preflightCount === 1 ? undefined : retryPreflight + } + }) + await clickButton('Install') + const firstInstance = container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await clickButton('Retry') + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await act(async () => { + finishRetryPreflight?.() + await retryPreflight + }) + await act(async () => {}) + + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + expect( + container + ?.querySelector('[data-testid="inline-command-terminal"]') + ?.getAttribute('data-instance') + ).not.toBe(firstInstance) + expect(findButton('Retry').disabled).toBe(true) + }) + + it('keeps the command failure authoritative over presence discovery', async () => { + await renderInteractivePanel({ freshnessSkillName: 'orca-cli' }) + await clickButton('Install') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + await rerenderInteractivePanel({ installed: true, freshnessSkillName: 'orca-cli' }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(container?.textContent).not.toContain('Installed') + expect(container?.querySelector('[data-testid="skill-freshness"]')).toBeNull() + expect(findButton('Retry').disabled).toBe(false) + }) + + it('keeps failed updates recoverable when installed re-check is hidden', async () => { + await renderInteractivePanel({ + installed: true, + installedCommand: UPDATE_COMMAND, + showRecheckWhenInstalled: false + }) + await clickButton('Update') + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(1) + }) + + expect(container?.textContent).toContain('Setup failed') + expect(container?.textContent).toContain('exited with code 1') + expect(findButton('Retry').disabled).toBe(false) + + await clickButton('Retry') + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: UPDATE_COMMAND }) + }) + + it('invalidates shared skill state before the direct completion re-check', async () => { + const calls: string[] = [] + mocks.skillsChanged.mockImplementation(() => calls.push('invalidate')) + const onRecheck = vi.fn(() => { + calls.push('recheck') + }) + await renderInteractivePanel({ freshnessSkillName: 'orca-cli', onRecheck }) + await clickButton('Install') + calls.length = 0 + + await act(async () => { + mocks.terminalProps.at(-1)?.onCommandFinished?.(0) + }) + + expect(calls).toEqual(['invalidate', 'recheck']) + }) + + it('re-enables Install after the setup shell exits so a failed attempt can retry', async () => { + await renderInteractivePanel() + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + + await act(async () => { + mocks.terminalProps.at(-1)?.onTerminalExit?.() + }) + + expect(findButton('Install').disabled).toBe(false) + expect(container?.querySelector('[data-testid="inline-command-terminal"]')).toBeNull() + + await clickButton('Install') + + expect(findButton('Install').disabled).toBe(true) + expect(mocks.terminalProps.at(-1)).toMatchObject({ command: INSTALL_COMMAND }) + }) }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 96bf0af5a8a..c141b9bf472 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useState, type ComponentProps, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' +import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice' +import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { @@ -15,51 +17,6 @@ import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -type AgentSkillSetupPanelVariant = 'card' | 'inline' -type SkillPrerequisiteStatus = Awaited> - -type AgentSkillSetupPanelProps = { - title: string - description: ReactNode - command: string - installedCommand?: string - terminalTitle: string - terminalAriaLabel: string - terminalWorktreeId: string - installed: boolean - loading: boolean - error: string | null - installDisabled?: boolean - terminalHeightPx?: number - terminalShellOverride?: string - leading?: ReactNode - icon?: ReactNode - variant?: AgentSkillSetupPanelVariant - className?: string - // Why: when an enclosing surface (e.g. a modal) already shows the title and - // status, hide the panel's own header row to avoid a duplicate heading. - hideHeader?: boolean - preInstallNotice?: ReactNode - getPrerequisiteStatus?: () => Promise - isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean - onBeforeOpenTerminal?: () => void | Promise - showInstallWhenInstalled?: boolean - showRecheckWhenInstalled?: boolean - installLabel?: string - installedInstallLabel?: string - // Why: defaults to 'outline' so settings panels stay unchanged; modals that make - // Install the sole footer CTA pass 'default' for a filled primary hierarchy. - installVariant?: ComponentProps['variant'] - actionHint?: ReactNode - openingHint?: ReactNode - footer?: ReactNode - onRecheck: () => void | Promise - // Why: when set, the installed pill reflects skill freshness and Re-check also - // refreshes the freshness inventory. Callers omit it for non-local runtimes, - // which the local-host-only freshness scan cannot vouch for. - freshnessSkillName?: string -} - export function AgentSkillSetupPanel({ title, description, @@ -102,7 +59,11 @@ export function AgentSkillSetupPanel({ translate('auto.components.settings.AgentSkillSetupPanel.updateLabel', 'Update') const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) + const [terminalAttempt, setTerminalAttempt] = useState(0) const [terminalOpening, setTerminalOpening] = useState(false) + const [setupAttemptRunning, setSetupAttemptRunning] = useState(false) + const [setupCommandFailedCode, setSetupCommandFailedCode] = useState(null) + const setupAttemptRunningRef = useRef(false) const [preInstallNoticeVisible, setPreInstallNoticeVisible] = useState( Boolean(preInstallNotice && !installed) ) @@ -116,6 +77,68 @@ export function AgentSkillSetupPanel({ // already-open terminal pinned to the command selected by the user's click. const openTerminalCommand = terminalCommand ?? activeCommand + const openSetupTerminal = (): void => { + if (terminalOpening || setupAttemptRunning) { + return + } + const nextCommand = + setupCommandFailedCode !== null && terminalCommand ? terminalCommand : activeCommand + setTerminalOpening(true) + if (setupCommandFailedCode !== null) { + setTerminalOpen(false) + } + void (async () => { + let shouldOpenTerminal = false + try { + await onBeforeOpenTerminal?.() + await refreshPreInstallNotice() + shouldOpenTerminal = true + } catch { + shouldOpenTerminal = false + } finally { + if (mountedRef.current) { + setTerminalOpening(false) + if (shouldOpenTerminal) { + setTerminalCommand(nextCommand) + setTerminalAttempt((attempt) => attempt + 1) + setTerminalOpen(true) + setupAttemptRunningRef.current = true + setSetupAttemptRunning(true) + } + } + } + })() + } + + // Why: PTY exit is the shell's status; OSC 133;D reports the install command. + const handleSetupCommandFinished = useCallback( + (bestEffortExitCode: number | null): void => { + // Nested shells can emit duplicate completion markers in one PTY chunk. + if (!setupAttemptRunningRef.current) { + return + } + setupAttemptRunningRef.current = false + setSetupAttemptRunning(false) + if (bestEffortExitCode !== null) { + setSetupCommandFailedCode(bestEffortExitCode === 0 ? null : bestEffortExitCode) + } + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + void onRecheck() + }, + [freshnessSkillName, onRecheck] + ) + + const handleTerminalExit = useCallback((): void => { + if (mountedRef.current) { + setupAttemptRunningRef.current = false + setTerminalOpen(false) + setSetupAttemptRunning(false) + } + notifyInstalledAgentSkillsChanged() + }, [mountedRef]) + useEffect(() => { if (!preInstallNotice) { setPreInstallNoticeVisible(false) @@ -180,36 +203,12 @@ export function AgentSkillSetupPanel({ const actionRow = (
    - {!installed || showInstallWhenInstalled ? ( + {(!installed || showInstallWhenInstalled) && setupCommandFailedCode === null ? ( ) : null} - {!installed || showRecheckWhenInstalled ? ( + {setupCommandFailedCode !== null || !installed || showRecheckWhenInstalled ? ( ) : null} {terminalOpening ? ( @@ -269,7 +278,7 @@ export function AgentSkillSetupPanel({ <> {error ?

    {error}

    : null} {/* hideHeader drops the title row; keep freshness so guided hubs still surface updates. */} - {installed && freshnessSkillName ? ( + {installed && freshnessSkillName && setupCommandFailedCode === null ? (
    @@ -286,7 +295,14 @@ export function AgentSkillSetupPanel({

    {title}

    - {loading && !installed ? ( + {setupCommandFailedCode !== null ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.setupFailed', + 'Setup failed' + )} + + ) : loading && !installed ? ( {translate( 'auto.components.settings.AgentSkillSetupPanel.68a468752e', @@ -322,6 +338,7 @@ export function AgentSkillSetupPanel({

    {description}

    ) : null} {actionRow} + {actionHint ?
    {actionHint}
    : null} {!installed && preInstallNotice && preInstallNoticeVisible ? (

    @@ -373,6 +390,7 @@ export function AgentSkillSetupPanel({

    ) : null} diff --git a/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts new file mode 100644 index 00000000000..9596d47d911 --- /dev/null +++ b/src/renderer/src/components/settings/agent-skill-setup-panel-props.ts @@ -0,0 +1,43 @@ +import type { ComponentProps, ReactNode } from 'react' +import type { Button } from '../ui/button' + +type AgentSkillSetupPanelVariant = 'card' | 'inline' +type SkillPrerequisiteStatus = Awaited> + +export type AgentSkillSetupPanelProps = { + title: string + description: ReactNode + command: string + installedCommand?: string + terminalTitle: string + terminalAriaLabel: string + terminalWorktreeId: string + installed: boolean + loading: boolean + error: string | null + installDisabled?: boolean + terminalHeightPx?: number + terminalShellOverride?: string + leading?: ReactNode + icon?: ReactNode + variant?: AgentSkillSetupPanelVariant + className?: string + // Enclosing modals can own the title and status. + hideHeader?: boolean + preInstallNotice?: ReactNode + getPrerequisiteStatus?: () => Promise + isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean + onBeforeOpenTerminal?: () => void | Promise + showInstallWhenInstalled?: boolean + showRecheckWhenInstalled?: boolean + installLabel?: string + installedInstallLabel?: string + // Modal footers can promote Install to the primary action. + installVariant?: ComponentProps['variant'] + actionHint?: ReactNode + openingHint?: ReactNode + footer?: ReactNode + onRecheck: () => void | Promise + // Freshness inventory is local-host-only. + freshnessSkillName?: string +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index c2479267e24..24991e01914 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -271,7 +271,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => { ssh.dispose() expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2) - expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0) }) it('drops a same-turn status row on command finished for SSH PTYs only', async () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index ff422e55de5..781625318bd 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -126,13 +126,13 @@ export function createParkedTerminalCommandStatusPolicy(options: { ) return { - onCommandFinished: (): void => { + onCommandFinished: (bestEffortExitCode: number | null): void => { if (disposed) { return } // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(worktreeId) + dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 49c11f55cb3..f4a4da3b9ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2095,13 +2095,13 @@ export function connectPanePty( // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — // routing both through this handler keeps the drop/interrupt semantics // identical across authority modes. - const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + const handleCommandFinished = (bestEffortExitCode: number | null): void => { clearCommandInferredPaneAgentAfterPtySideEffects() visibleForegroundSamplePending = false const shouldDeferStatusDrop = paneForegroundAgentTracker.onCommandFinished() // Why: the finished command may have moved HEAD or the index (e.g. // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId) + dispatchTerminalCommandFinishedEvent(deps.worktreeId, bestEffortExitCode) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[cacheKey] const inferenceResult = flushPendingInterruptInference() diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 5a304b41846..2bdd0e4b666 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -2,12 +2,16 @@ export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finis export type TerminalCommandFinishedEventDetail = { worktreeId: string + // OSC 133;D may omit the command's exit code. + exitCode: number | null } // Why: the OSC 133;D handler lives in a per-pane closure; a window event lets -// decoupled consumers (e.g. git status refresh) react to shell commands -// finishing without reaching into terminal internals. -export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { +// decoupled consumers react without reaching into terminal internals. +export function dispatchTerminalCommandFinishedEvent( + worktreeId: string, + exitCode: number | null +): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { return @@ -15,7 +19,7 @@ export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void { window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId } + detail: { worktreeId, exitCode } }) ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9e1cd986862..76557b35550 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5319,7 +5319,10 @@ "5f818f12ab": "Preparing...", "4c05b9d7cb": "Preparing setup terminal.", "installLabel": "Install", - "updateLabel": "Update" + "updateLabel": "Update", + "setupCommandFailed": "The setup command exited with code {{value0}}. This error will clear after a successful retry.", + "setupFailed": "Setup failed", + "retrySetup": "Retry" }, "AgentsPane": { "d83834f5e6": "Detecting installed agents…", From f998f7ec62b3f740eb53c16dbdc82b5926c85bfc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:53:02 -0700 Subject: [PATCH 048/123] feat(updater): add hourly dev channel and build switching (#11250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(updater): add hourly dev channel and build switching Adds an hourly macOS build channel plus a dev-only surface for switching update channels and jumping to any published build, including older ones. Hourly builds publish to a separate stablyai/orca-hourly repo. The routine update path resolves tags from the main repo's releases atom feed, which exposes only its 10 newest entries — 24 hourly tags a day would evict every stable/RC entry there and leave real users with nothing to update to. Hourly artifacts carry the release bundle id and Developer ID signature so Squirrel.Mac can swap them in place; only notarization is skipped, which in-place updates never check. Version tails are stripped to the base (1.4.160-hourly., not 1.4.160-rc.3-hourly.) so hourlies sort below both rc.N and stable and are reachable only by an explicit pinned jump, never by an ordinary check. The picker is revealed by Option-clicking the Updates header, matching the Help menu's existing hidden admin affordance. Pinned jumps set allowDowngrade and release the feed on every settle path so a jump can never leave background checks permanently deferred. * chore(hourly): create orca-hourly and add token provisioning script Adds setup-hourly-release-token.sh, which provisions HOURLY_RELEASE_TOKEN without the value ever reaching stdout, argv, or shell history: it is read with `read -rs`, passed to gh through GH_TOKEN in the environment rather than as an argument (argv is world-readable via ps), piped into `gh secret set` on stdin, and scrubbed by an EXIT trap. Verification creates and deletes a draft release in orca-hourly to prove Contents:write for real rather than trusting the permission checkbox. Drafts are absent from the releases atom feed, so the probe cannot disturb users. Refuses to run without a controlling terminal instead of falling through having set nothing, and refuses to run under xtrace, which would echo the token on every expansion. * fix(updater): address review feedback on the hourly channel Renderer: - Guard listBuilds against out-of-order responses. activeChannel flips once getVersion resolves, and rapid channel clicks stack requests, so a slower earlier load could land last and fill the list with builds from a channel the picker was no longer showing. - Selecting the running build's own channel now clears the override instead of pinning it. There was previously no way back to "follow this build's channel", so merely opening the panel left background checks pinned. - Validate releaseChannelOverride on hydration, matching every other enum-like field in that function. Main: - Exclude pinned jumps from recordCompletedUpdateCheck() in update-available. A dev browsing the picker was persisting lastUpdateCheckAt and suppressing the next real background check for a full day. - parseHourlyVersionStamp now anchors on the whole version and round-trips the parsed fields. It accepted garbage prefixes, and Date.UTC rolled impossible dates forward, so ...hourly.202602300000 rendered as March 2. Workflow: - Publish into a draft and flip it live only after the manifest check. The window between creating the release and verifying its assets previously exposed a tag the picker would offer and the download would 404 on; a draft is invisible to listReleaseBuilds, so a job that dies in that window — including a hard kill by the job timeout, which runs no cleanup step — leaves nothing user-visible behind. - Add a failure handler that discards the draft, gated on the publish step not having succeeded so a later prune failure cannot delete a live release. - Align retry budgets with the job timeout (was 60min against a worst case of ~185min, so a mid-retry kill skipped the cleanup that step exists for). - Exclude drafts from the freshness and retention queries. - persist-credentials: false; the job only reads this repo and never pushes. * refactor(hourly): authenticate with a GitHub App instead of a PAT A fine-grained PAT expires, and the hourly build would then fail silently on a schedule nobody watches. A GitHub App's private key has no expiry, so this is set up once. It is also owned by the org rather than by the person who created it, so the credential survives that person leaving. The workflow mints a short-lived installation token via actions/create-github-app-token and passes it as GH_TOKEN. Installation tokens live one hour, which is ample: this job runs no tests, no notarization, and no Windows signing, so it is pack + upload. The retry budgets and job timeout are re-sized to that reality rather than copied from the release pipeline, whose 3x45 publish budget exists for notarization and SignPath. setup-hourly-release-token.sh now provisions HOURLY_RELEASE_APP_ID and HOURLY_RELEASE_APP_PRIVATE_KEY. The key is redirected from a file straight into `gh secret set` on stdin, so its contents never enter a shell variable, argv, or the terminal. * fix(hourly): make the xtrace guard fire and cover cancelled runs The xtrace guard disabled tracing before testing for it, so `[[ -o xtrace ]]` read the state the previous line had just cleared and never fired. `bash -x` ran straight through, tracing exactly the key handling the guard exists to prevent. Test first, then disable. The draft cleanup only ran on failure(), but a run stopped from the Actions UI is cancelled(), not failed — a manual cancel mid-publish stranded the draft. Cover both. --- .github/workflows/hourly-mac-build.yml | 330 ++++++++++++++++++ config/electron-builder.config.cjs | 26 +- .../scripts/electron-builder-config.test.mjs | 79 +++++ config/scripts/hourly-build-version.mjs | 43 +++ config/scripts/hourly-build-version.test.mjs | 30 ++ config/scripts/setup-hourly-release-token.sh | 95 +++++ .../runtime/rpc/methods/client-ui-schemas.ts | 1 + src/main/updater-events.ts | 39 ++- src/main/updater-release-builds.test.ts | 113 ++++++ src/main/updater-release-builds.ts | 102 ++++++ src/main/updater.ts | 145 +++++++- .../window/attach-main-window-services.ts | 20 ++ src/preload/api-types.ts | 3 + src/preload/index.ts | 1 + .../settings/GeneralUpdateSettingsSection.tsx | 36 +- .../settings/ReleaseChannelSection.tsx | 281 +++++++++++++++ src/renderer/src/i18n/locales/en.json | 15 + src/renderer/src/store/slices/ui.ts | 15 + src/renderer/src/web/web-preload-api.ts | 11 + src/shared/release-channel.test.ts | 95 +++++ src/shared/release-channel.ts | 84 +++++ src/shared/types.ts | 12 +- 22 files changed, 1536 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/hourly-mac-build.yml create mode 100644 config/scripts/hourly-build-version.mjs create mode 100644 config/scripts/hourly-build-version.test.mjs create mode 100755 config/scripts/setup-hourly-release-token.sh create mode 100644 src/main/updater-release-builds.test.ts create mode 100644 src/main/updater-release-builds.ts create mode 100644 src/renderer/src/components/settings/ReleaseChannelSection.tsx create mode 100644 src/shared/release-channel.test.ts create mode 100644 src/shared/release-channel.ts diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml new file mode 100644 index 00000000000..d256faecb88 --- /dev/null +++ b/.github/workflows/hourly-mac-build.yml @@ -0,0 +1,330 @@ +name: Hourly macOS Dev Build + +# Why: gives developers a signed macOS build of main every hour that the in-app +# updater can install directly, without waiting for an RC cut. +# +# Deliberately narrow scope: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI +# and release-cut remain the gates that matter. +# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's +# signature, not its notarization, so in-place updates still work while the +# ~10min notary round trip is skipped 24x a day. +# +# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly +# tags a day would evict every stable/RC entry and break updates for real users. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a +# GitHub App installed on orca-hourly with Contents: Read and write. Its private +# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the +# credential belongs to the org rather than to whoever created it. +# +# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is ample here: this job skips tests, +# notarization, and Windows signing entirely, so a run is pack + upload and lands +# well inside that. If one ever did overrun, the release is still an unpublished +# draft at that point, so nothing user-visible breaks. + +on: + schedule: + # Top of every hour. Skipped automatically when main has not moved. + - cron: '0 * * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last hourly + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: hourly-mac-build + cancel-in-progress: false + +env: + HOURLY_REPO: stablyai/orca-hourly + # Keep ~3 days of history so a regression can be bisected across a weekend. + HOURLY_RETAIN_COUNT: 72 + +jobs: + build-hourly-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 120: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — there is no notarization, Windows signing, or test phase. + timeout-minutes: 120 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + # Why: this job only reads stablyai/orca and never pushes; every write + # goes to the hourly repo through a minted App token passed by env. + # Not persisting the checkout credential shrinks the blast radius if a + # build step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Mint hourly repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-hourly + + # Why: main is often idle overnight. Rebuilding an unchanged commit burns a + # runner hour and adds a redundant tag to the retention window. + - name: Check whether main moved since the last hourly + id: freshness + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + head_sha="$(git rev-parse HEAD)" + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + if [[ "$FORCED" == "true" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "Forced dispatch; building $head_sha." + exit 0 + fi + # The previous hourly records its source commit in the release body. + # Drafts are excluded: an unpublished leftover never shipped, so treating + # it as "the last build" would skip a build that never actually happened. + last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \ + --jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)" + if [[ -z "$last_body" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "No prior hourly release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \ + --jq '.body | capture("commit `(?[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)" + if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then + echo "should_build=false" >>"$GITHUB_OUTPUT" + echo "main is unchanged since $last_body ($last_sha); skipping." + else + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "main moved to $head_sha (last hourly built $last_sha); building." + fi + + - name: Setup pnpm + if: steps.freshness.outputs.should_build == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + if: steps.freshness.outputs.should_build == 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes an hourly installable over an existing Orca, so + # a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute hourly version + id: hourly + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT" + + - name: Build app + if: steps.freshness.outputs.should_build == 'true' + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: hourly builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted dev artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + - name: Create hourly release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: v${{ steps.hourly.outputs.version }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + short_sha="${SHA:0:12}" + # Why create it up front: electron-builder then uploads into a known tag + # rather than inferring one from package.json. + # + # Why --draft: everything between here and the manifest check is a window + # where the release exists but has no installable assets. A draft is + # absent from the releases list and from listReleaseBuilds, so a job that + # dies in that window — including a hard kill by the job timeout, which + # runs no cleanup step at all — leaves something invisible rather than a + # tag the picker offers and the download 404s on. It is flipped live only + # after the manifest is verified. + gh release create "$TAG" \ + --repo "$HOURLY_REPO" \ + --title "$TAG" \ + --draft \ + --notes "Automated hourly macOS dev build from commit \`$short_sha\`. + + Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA). + + **Unvetted.** No tests ran. Signed but not notarized — installable + through Orca's in-app updater, but a manual download will be + Gatekeeper-quarantined." + echo "tag=$TAG" >>"$GITHUB_OUTPUT" + + - name: Publish hourly macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why lower than the release pipeline's 3x45: that budget is sized for + # notarization and Windows signing, neither of which runs here. An attempt + # is pack + upload only. + timeout_minutes: 25 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-hourly. + GH_TOKEN: ${{ steps.app_token.outputs.token }} + ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + # Deleting inline under `set -e` would also let the delete's exit code + # preempt this explicit failure. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Hourly draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease + echo "Published $TAG" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name, so the next run for the same minute would collide. + # + # Why it is gated on publish_live not having succeeded: a later failure (the + # prune step) must not delete a release that already went live and that users + # may already be installing. A job killed by the outer timeout runs no steps + # at all — which is exactly why the release stays a draft until verified. + # Why cancelled() too: a run stopped from the Actions UI is not a failure(), + # so without it a manual cancel mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$HOURLY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old hourly releases + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + run: | + set -euo pipefail + # Why: --cleanup-tag so pruning does not leave orphan tags behind that + # keep showing up in tag lists with no release or assets attached. + # Drafts are excluded so retention counts shipped builds only; a stale + # draft is handled by the failure path, not by the retention window. + stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \ + --jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + echo "Pruning $tag" + gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index da0f690ad80..39a6df73b05 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -16,9 +16,14 @@ const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibilit const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' +// Why: hourly dev builds must carry the *release* identity — same bundle id and +// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed +// Orca. Only notarization is skipped, which in-place updates never check. +const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION +const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', @@ -65,7 +70,11 @@ const winSpeechNativeResource = { module.exports = { appId, productName: 'Orca', - ...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}), + ...(hourlyBuildVersion + ? { extraMetadata: { version: hourlyBuildVersion } } + : localBuildVersion + ? { extraMetadata: { version: localBuildVersion } } + : {}), directories: { buildResources: 'resources/build' }, @@ -321,7 +330,11 @@ module.exports = { // explicit release path so production artifacts remain strict while dev // artifacts do not fail with broken ad-hoc launch behavior. hardenedRuntime: isMacRelease, - notarize: isMacRelease, + // Why: Squirrel.Mac validates the replacement bundle's signature, not its + // notarization, so hourly builds stay installable while skipping the ~10min + // notary round trip 24x a day. A manually-downloaded hourly zip will be + // Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel. + notarize: isMacRelease && !isMacHourly, extraResources: [ ...commonExtraResources, ...createPackagedRuntimeNodeModuleResources('darwin'), @@ -461,8 +474,11 @@ module.exports = { publish: { provider: 'github', owner: 'stablyai', - repo: 'orca', - releaseType: 'release' + // Why: hourly tags must never enter the main repo's releases atom feed — it + // exposes only the 10 newest entries, so 24 hourly tags a day would evict + // every stable/RC entry and strand users on a feed with nothing to install. + repo: isMacHourly ? 'orca-hourly' : 'orca', + releaseType: isMacHourly ? 'prerelease' : 'release' } } diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 1dd0cf78ff3..b9c4a499118 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,6 +19,39 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_LOCAL_BUILD_VERSION' +] + +/** Re-requires the config under a temporary env, then restores env and module cache. */ +function withEnv(env, assert) { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of MUTABLE_BUILD_ENV) { + delete process.env[key] + } + Object.assign(process.env, env) + delete require.cache[configPath] + assert(require('../electron-builder.config.cjs')) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } +} + +const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) + describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -267,6 +300,52 @@ describe('electron-builder config', () => { } }) + // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the + // same bundle id and a valid Developer ID signature. A hourly built on the local + // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real + // Orca — the whole point of the channel. + it('builds hourly artifacts with the release signing identity', () => { + withHourlyEnv((config) => { + expect(config.mac.appId).toBeUndefined() + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.forceCodeSigning).toBe(true) + }) + }) + + // Why: notarization is the one release step hourly skips; in-place updates never + // check it, and 24 notary round trips a day is the cost being avoided. + it('skips notarization only for hourly builds', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(false) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + }) + + // Why: the main repo's releases atom feed exposes only its 10 newest entries. + // Publishing 24 hourly tags a day there would evict every stable/RC entry and + // break update checks for every real user. + it('publishes hourly builds to the separate hourly repo', () => { + withHourlyEnv((config) => { + expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) + }) + expect(electronBuilderConfig.publish).toMatchObject({ + repo: 'orca', + releaseType: 'release' + }) + }) + + it('stamps hourly packages with the hourly version', () => { + withEnv( + { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) + } + ) + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs new file mode 100644 index 00000000000..6ed30a05916 --- /dev/null +++ b/config/scripts/hourly-build-version.mjs @@ -0,0 +1,43 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createHourlyBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Hourly build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()) + ].join('') + // Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested hourly builds to RC users. Stripping + // to the base parks hourlies below both rc.N and stable ('hourly' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-hourly.${stamp}` +} + +export function getHourlyBuildIdentity(now = new Date()) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + return { commit, version: createHourlyBuildVersion(packageJson.version, now) } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getHourlyBuildIdentity() + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`) +} diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs new file mode 100644 index 00000000000..e75951c481a --- /dev/null +++ b/config/scripts/hourly-build-version.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { createHourlyBuildVersion } from './hourly-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createHourlyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe( + '1.4.160-hourly.202607280405' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly + // builds to RC users. Dropping it parks hourlies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so hourlies never outrank the rc series', () => { + const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z')) + expect(version).toBe('1.4.160-hourly.202607281400') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan( + 0 + ) + }) + + it('rejects invalid input', () => { + expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh new file mode 100755 index 00000000000..f21ce7b5bb7 --- /dev/null +++ b/config/scripts/setup-hourly-release-token.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Provisions the credentials hourly-mac-build.yml uses to publish into +# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo +# running the workflow, and hourly artifacts are published to a different one. +# +# A GitHub App is used rather than a PAT because its private key does not expire +# — no yearly rotation — and it belongs to the org rather than to the person who +# created it, so it survives that person leaving. +# +# The key is read from a file and piped straight into `gh secret set`. It is never +# echoed, never passed as a command-line argument (argv is world-readable via +# `ps`), and never copied anywhere on disk. +# +# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem] +# +set -euo pipefail + +# Guard: xtrace would echo the key to stderr on every expansion. Test before +# disabling, or the check reads the state this line just cleared and never fires. +if [[ -o xtrace ]]; then + echo "Refusing to run with xtrace enabled; it would echo the private key." >&2 + exit 1 +fi +set +x + +MAIN_REPO="stablyai/orca" +HOURLY_REPO="stablyai/orca-hourly" +APP_ID_SECRET="HOURLY_RELEASE_APP_ID" +APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +# Setting repo secrets requires admin; check before asking for anything. +if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then + fail "You need admin on $MAIN_REPO to set repository secrets." +fi +gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 || + fail "$HOURLY_REPO does not exist or you cannot see it." + +cat < Contents: Read and write + (leave everything else alone) + 4. "Where can this app be installed?" -> Only on this account + 5. Create, then note the App ID shown at the top of the page. + 6. Generate a private key (bottom of the page) — a .pem downloads. + 7. Install App -> Only select repositories -> $HOURLY_REPO + +EOF + +read -rp "App ID (numeric): " APP_ID +[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-}" + +KEY_PATH="${1:-}" +if [[ -z "$KEY_PATH" ]]; then + read -rp "Path to the downloaded .pem: " KEY_PATH +fi +# Expand a leading ~ so a pasted path works without quoting rules. +KEY_PATH="${KEY_PATH/#\~/$HOME}" +[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH" +grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" || + fail "$KEY_PATH does not look like a PEM private key." + +echo "Storing $APP_ID_SECRET in $MAIN_REPO..." +printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" || + fail "Could not set $APP_ID_SECRET." + +# Piped on stdin so the key never appears in argv or in shell history. +echo "Storing $APP_KEY_SECRET in $MAIN_REPO..." +gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" || + fail "Could not set $APP_KEY_SECRET." + +echo +echo "Done. Both secrets are set on $MAIN_REPO." +echo +echo "Delete your local copy of the key — the workflow reads it from the secret," +echo "and a .pem sitting in ~/Downloads is a standing credential:" +echo " rm '$KEY_PATH'" +echo +echo "Smoke-test the pipeline without waiting for the hour (after this merges):" +echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 21a6fa8f47c..2e38b4848c0 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -250,6 +250,7 @@ const UiUpdateFields = z lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), dismissedUpdateNudgeId: NullableString.optional(), + releaseChannelOverride: z.enum(['stable', 'rc', 'hourly']).nullable().optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 411bb1a7184..1e3f7de3395 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -32,6 +32,7 @@ type UpdaterHandlerContext = { isQuitAndInstallHandoffActive: () => boolean hasInstallableDownloadedVersion: () => boolean isLocalBuildCheck: () => boolean + isPinnedBuildCheck: () => boolean shouldHandleUpdaterErrorEvent: () => boolean clearUpdateAvailableEventPending: (attemptId: number | null) => void isActiveUpdateCheckAttempt: (attemptId: number) => boolean @@ -74,6 +75,7 @@ export function registerAutoUpdaterHandlers({ isQuitAndInstallHandoffActive, hasInstallableDownloadedVersion, isLocalBuildCheck, + isPinnedBuildCheck, shouldHandleUpdaterErrorEvent, clearUpdateAvailableEventPending, isActiveUpdateCheckAttempt, @@ -162,8 +164,12 @@ export function registerAutoUpdaterHandlers({ const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) - // Release checks remain newer-only; validated local builds may intentionally downgrade. - if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { + // Release checks remain newer-only; validated local builds and pinned dev jumps may intentionally downgrade. + if ( + !isLocalBuildCheck() && + !isPinnedBuildCheck() && + compareVersions(info.version, app.getVersion()) <= 0 + ) { clearAvailableUpdateContext() if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence. @@ -182,9 +188,10 @@ export function registerAutoUpdaterHandlers({ markUpdateAvailableEventPending(attemptId) void (async () => { try { - const changelog = isLocalBuildCheck() - ? null - : await fetchChangelog(info.version, app.getVersion()).catch(() => null) + const changelog = + isLocalBuildCheck() || isPinnedBuildCheck() + ? null + : await fetchChangelog(info.version, app.getVersion()).catch(() => null) // Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast. if (!isActiveUpdateCheckAttempt(attemptId)) { @@ -197,7 +204,10 @@ export function registerAutoUpdaterHandlers({ // Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state. setAvailableVersion(info.version) setAvailableReleaseUrl(null) - if (!isLocalBuildCheck()) { + // Why: a pinned dev jump is not a release check. Letting it call + // recordCompletedUpdateCheck() would persist lastUpdateCheckAt and + // suppress the next real background check for a full day. + if (!isLocalBuildCheck() && !isPinnedBuildCheck()) { if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) @@ -226,9 +236,12 @@ export function registerAutoUpdaterHandlers({ const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() const localBuildCheck = isLocalBuildCheck() + // Why: an unpinned outcome must hand the feed back, else the pin blocks every + // later background check for the process lifetime. + const pinnedBuildCheck = isPinnedBuildCheck() setUserInitiatedCheck(false) clearAvailableUpdateContext() - if (!localBuildCheck) { + if (!localBuildCheck && !pinnedBuildCheck) { if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) @@ -240,7 +253,7 @@ export function registerAutoUpdaterHandlers({ } } sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) - if (localBuildCheck) { + if (localBuildCheck || pinnedBuildCheck) { restoreReleaseUpdateSource() } }) @@ -256,8 +269,12 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('update-downloaded', (info) => { clearBackgroundCheckLaunchPending() - // Release downloads remain newer-only; the local source was validated before checking. - if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { + // Release downloads remain newer-only; the local source was validated before checking, and a pinned jump is explicit. + if ( + !isLocalBuildCheck() && + !isPinnedBuildCheck() && + compareVersions(info.version, app.getVersion()) <= 0 + ) { clearAvailableUpdateContext() sendStatus({ state: 'not-available' }) return @@ -302,7 +319,7 @@ export function registerAutoUpdaterHandlers({ return } sendErrorStatus(message, wasUserInitiated || undefined) - if (isLocalBuildCheck()) { + if (isLocalBuildCheck() || isPinnedBuildCheck()) { restoreReleaseUpdateSource() } }) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts new file mode 100644 index 00000000000..2ab08ae62cb --- /dev/null +++ b/src/main/updater-release-builds.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fetchMock = vi.fn() +vi.mock('electron', () => ({ net: { fetch: (...args: unknown[]) => fetchMock(...args) } })) + +const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') + +function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + json: () => Promise.resolve(body) + } +} + +const release = (tag: string, extra: Record = {}) => ({ + tag_name: tag, + draft: false, + published_at: '2026-07-28T14:00:00Z', + html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + ...extra +}) + +describe('listReleaseBuilds', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('lists hourly builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-hourly.202607280900'), + release('v1.4.160-hourly.202607281400'), + release('v1.4.160-hourly.202607281000') + ]) + ) + + const builds = await listReleaseBuilds('hourly') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-hourly') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-hourly.202607281400', + '1.4.160-hourly.202607281000', + '1.4.160-hourly.202607280900' + ]) + }) + + // Why: the main repo serves stable and rc from one endpoint, so an unfiltered + // list would offer RC tags under the Stable channel. + it('separates stable from rc in the shared main repo', async () => { + fetchMock.mockResolvedValue( + jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) + ) + + await expect(listReleaseBuilds('stable').then((b) => b.map((x) => x.version))).resolves.toEqual( + ['1.4.159', '1.4.158'] + ) + + fetchMock.mockResolvedValue( + jsonResponse([release('v1.4.160-rc.2'), release('v1.4.159'), release('v1.4.158')]) + ) + await expect(listReleaseBuilds('rc').then((b) => b.map((x) => x.version))).resolves.toEqual([ + '1.4.160-rc.2' + ]) + }) + + // Why: a draft release has no downloadable assets; offering it makes the + // switch action fail with a 404 after the user commits to it. + it('skips drafts and unparseable tags', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.159'), + release('v1.4.158', { draft: true }), + release('not-a-version'), + { tag_name: 42 } + ]) + ) + + const builds = await listReleaseBuilds('stable') + expect(builds.map((build) => build.version)).toEqual(['1.4.159']) + }) + + it('surfaces a rate limit as an actionable message', async () => { + fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 })) + await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i) + }) + + it('reports a missing hourly repo distinctly', async () => { + fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 404 })) + await expect(listReleaseBuilds('hourly')).rejects.toThrow(/No releases repository/i) + }) +}) + +describe('resolveTargetBuild', () => { + it('pins an hourly tag at the hourly repo download path', () => { + expect(resolveTargetBuild('hourly', 'v1.4.160-hourly.202607281400')).toEqual({ + tag: 'v1.4.160-hourly.202607281400', + version: '1.4.160-hourly.202607281400', + feedUrl: + 'https://github.com/stablyai/orca-hourly/releases/download/v1.4.160-hourly.202607281400' + }) + }) + + it('pins a stable tag at the main repo download path', () => { + expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe( + 'https://github.com/stablyai/orca/releases/download/v1.4.159' + ) + }) + + it('rejects a tag that is not a version', () => { + expect(() => resolveTargetBuild('stable', 'main')).toThrow(/not a valid release tag/) + }) +}) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts new file mode 100644 index 00000000000..a7ff6089baf --- /dev/null +++ b/src/main/updater-release-builds.ts @@ -0,0 +1,102 @@ +import { net } from 'electron' +import { + getReleaseRepoForChannel, + getVersionChannel, + normalizeTagToVersion, + sortReleaseBuildsNewestFirst, + type ReleaseBuild, + type ReleaseChannel +} from '../shared/release-channel' +import { isValidVersion } from './updater-fallback' + +const FETCH_TIMEOUT_MS = 8000 +const MAX_LISTED_BUILDS = 100 + +function getReleasesApiUrl(repo: string): string { + return `https://api.github.com/repos/${repo}/releases?per_page=${MAX_LISTED_BUILDS}` +} + +export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string { + return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}` +} + +type GitHubReleaseEntry = { + tag_name?: unknown + draft?: unknown + published_at?: unknown + html_url?: unknown +} + +function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuild | null { + if (typeof entry.tag_name !== 'string' || entry.draft === true) { + return null + } + const tag = entry.tag_name + const version = normalizeTagToVersion(tag) + const channel = getVersionChannel(version) + if (!isValidVersion(version) || !channel) { + return null + } + return { + tag, + version, + channel, + publishedAt: typeof entry.published_at === 'string' ? entry.published_at : null, + releaseUrl: + typeof entry.html_url === 'string' + ? entry.html_url + : `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}` + } +} + +/** + * Lists published releases for a channel so the dev picker can offer an exact + * build — including older ones — to jump to. + * + * Why the REST API rather than the atom feed the routine update path uses: the + * feed caps at the 10 newest entries, which cannot express "jump back to + * yesterday's hourly". This runs only on explicit dev interaction, so its + * unauthenticated rate limit never touches background checks. + */ +export async function listReleaseBuilds(channel: ReleaseChannel): Promise { + const repo = getReleaseRepoForChannel(channel) + const res = await net.fetch(getReleasesApiUrl(repo), { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!res.ok) { + if (res.status === 404) { + throw new Error(`No releases repository found at ${repo}.`) + } + if (res.status === 403 || res.status === 429) { + throw new Error('GitHub rate limit reached. Try again in a few minutes.') + } + throw new Error(`Could not list ${channel} builds (HTTP ${res.status}).`) + } + const payload: unknown = await res.json() + if (!Array.isArray(payload)) { + throw new Error(`Could not read the ${channel} release list.`) + } + const builds = payload + .map((entry) => parseReleaseEntry(entry as GitHubReleaseEntry, repo)) + .filter((build): build is ReleaseBuild => build !== null) + // Why: the main repo serves both stable and rc, so filter to the asked-for channel. + .filter((build) => build.channel === channel) + return sortReleaseBuildsNewestFirst(builds) +} + +export type ResolvedTargetBuild = { + tag: string + version: string + feedUrl: string +} + +/** Resolves a tag the user picked into a pinned generic feed URL. */ +export function resolveTargetBuild(channel: ReleaseChannel, tag: string): ResolvedTargetBuild { + const version = normalizeTagToVersion(tag) + if (!isValidVersion(version)) { + throw new Error(`"${tag}" is not a valid release tag.`) + } + const repo = getReleaseRepoForChannel(channel) + return { tag, version, feedUrl: getReleaseDownloadUrlForRepo(repo, tag) } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 3f9cb2a531c..838c304a290 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,6 +44,8 @@ import { requestServeUpdateHandoff } from './serve-update-handoff' import type { LocalBuildFeed } from './local-builds/local-build-feed-server' +import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -140,9 +142,16 @@ let downloadInFlight = false /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | 'local' = 'release' +let activeUpdateSource: 'release' | 'local' | 'hourly' = 'release' let activeLocalBuildFeed: LocalBuildFeed | null = null let localBuildSelectionInProgress = false +// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade +// like local builds — but off a real release feed, not a loopback server. +let pinnedBuildSelectionInProgress = false +// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a +// deliberate downgrade, so newer-only gates must yield to it too. +let isPinnedBuildActive = false +let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null function getAutoUpdater(): ElectronAutoUpdater { if (!autoUpdater) { @@ -167,9 +176,13 @@ function closeLocalBuildFeed(): void { function restoreReleaseUpdateSource(): void { closeLocalBuildFeed() activeUpdateSource = 'release' + isPinnedBuildActive = false if (autoUpdater) { autoUpdater.allowDowngrade = false autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + autoUpdater.allowPrerelease = includePrereleaseActive } } @@ -265,7 +278,7 @@ function sendStatus(status: UpdateStatus): void { } const sourcedStatus: UpdateStatus = - activeUpdateSource === 'local' ? { ...status, source: 'local' } : status + activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) if (isUpdateCheckResultState(status.state)) { @@ -318,6 +331,12 @@ function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant if (options?.includePrerelease) { return 'prerelease' } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; 'hourly' needs an explicit tag, so it is + // not a routine-check variant. + if (getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } return 'default' } @@ -573,7 +592,10 @@ function getKnownReleaseUrl(): string | undefined { function hasInstallableDownloadedVersion(): boolean { return ( availableVersion !== null && - (activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0) + // Why: local builds and pinned dev jumps may intentionally move backwards. + (activeUpdateSource !== 'release' || + isPinnedBuildActive || + compareVersions(availableVersion, app.getVersion()) > 0) ) } @@ -823,6 +845,14 @@ async function sendCheckFailureStatus( sendLocalBuildErrorAndRestore(message, userInitiated) return } + if (isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the + // error, or the pin blocks background checks for the process lifetime. + clearAvailableUpdateContext() + restoreReleaseUpdateSource() + sendStatus({ state: 'error', message, userInitiated }) + return + } const failureKey = getCheckFailureKey(message, userInitiated) if ( source === 'promise' && @@ -1266,7 +1296,14 @@ function retryPrereleaseFallbackAfterMissingManifest( function runBackgroundUpdateCheck( nudgeId: string | null = getPersistedPendingUpdateNudgeId() ): boolean { - if (activeUpdateSource === 'local' || localBuildSelectionInProgress) { + // Why: a pinned dev jump owns the feed until it settles; a background check + // would repoint it mid-flight and download the wrong build. + if ( + activeUpdateSource !== 'release' || + isPinnedBuildActive || + localBuildSelectionInProgress || + pinnedBuildSelectionInProgress + ) { return false } if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { @@ -1340,11 +1377,15 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { void checkForLocalBuildFromMenu() return } - if (localBuildSelectionInProgress) { + if (options?.targetTag && options.channel) { + void checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { return } if ( - activeUpdateSource === 'local' && + activeUpdateSource !== 'release' && (currentStatus.state === 'checking' || currentStatus.state === 'downloading') ) { return @@ -1466,12 +1507,83 @@ async function checkForLocalBuildFromMenu(): Promise { } } +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) +} + +/** + * Pins the updater at one exact release tag and checks it, so a dev can move to + * any published build on any channel — including an older one. + * + * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's + * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt + * settles so ordinary background checks never inherit it. + */ +async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + sendStatus({ state: 'not-available', userInitiated: true }) + return + } + if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { + return + } + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { + return + } + pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + sendStatus({ state: 'not-available', userInitiated: true }) + return + } + closeLocalBuildFeed() + activeUpdateSource = channel === 'hourly' ? 'hourly' : 'release' + isPinnedBuildActive = true + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + clearAvailableUpdateContext() + activeUpdateNudgeId = null + userInitiatedCheck = true + sendStatus({ state: 'checking', userInitiated: true }) + + const updater = getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + availableReleaseUrl = target.feedUrl + const attemptId = beginUpdateCheckAttempt() + markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + userInitiatedCheck = false + clearAvailableUpdateContext() + restoreReleaseUpdateSource() + sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + pinnedBuildSelectionInProgress = false + } +} + export function isQuittingForUpdate(): boolean { return quittingForUpdate } export function quitAndInstall(): void { - if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) { + if ( + localBuildSelectionInProgress || + pinnedBuildSelectionInProgress || + pendingQuitAndInstallTimer || + quitAndInstallInProgress + ) { return } @@ -1562,14 +1674,18 @@ export function dismissNudge(): void { } /** - * The user closed an offered update without taking it. For a local build that ends the session: - * nothing will consume the local feed now, so release checks must stop being deferred. + * The user closed an offered update without taking it. For a local build or a + * pinned dev jump that ends the session: nothing will consume that feed now, so + * release checks must stop being deferred. */ export function dismissAvailableUpdate(): void { - if (activeUpdateSource !== 'local' || localBuildSelectionInProgress) { + if (activeUpdateSource === 'release' && !isPinnedBuildActive) { return } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the local feed and allowDowngrade. + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. if (currentStatus.state !== 'available') { return } @@ -1589,6 +1705,7 @@ export function setupAutoUpdater( getDismissedUpdateNudgeId?: () => string | null setPendingUpdateNudgeId?: (id: string | null) => void setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null installMode?: UpdateInstallMode } ): void { @@ -1600,6 +1717,7 @@ export function setupAutoUpdater( _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null updateInstallMode = opts?.installMode ?? 'interactive' lastInstallDeferralVersion = { download: null, install: null } @@ -1669,6 +1787,9 @@ export function setupAutoUpdater( isQuitAndInstallHandoffActive, hasInstallableDownloadedVersion, isLocalBuildCheck: () => activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not + // reject them for being older than the running version. + isPinnedBuildCheck: () => isPinnedBuildActive, shouldHandleUpdaterErrorEvent, performQuitAndInstall, clearUpdateAvailableEventPending, @@ -1733,7 +1854,7 @@ export function setupAutoUpdater( } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || downloadInFlight) { + if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { return } // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ba51231c02a..5984fe715af 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -6,9 +6,11 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult, + ReleaseBuildListResult, UpdateCheckOptions, WorktreeStartupLaunch } from '../../shared/types' +import { RELEASE_CHANNELS, type ReleaseChannel } from '../../shared/release-channel' import { acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -38,6 +40,7 @@ import { setupAutoUpdater, dismissAvailableUpdate, dismissNudge, + listAvailableReleaseBuilds, type UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' @@ -175,6 +178,7 @@ export function attachMainWindowServices( setDismissedUpdateNudgeId: (id) => { store.updateUI({ dismissedUpdateNudgeId: id }) }, + getReleaseChannelOverride: () => store.getUI().releaseChannelOverride ?? null, installMode: options?.updateInstallMode }) logStartupMilestone('updater-setup-done') @@ -526,6 +530,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.removeHandler('updater:quitAndInstall') ipcMain.removeHandler('updater:dismissNudge') ipcMain.removeHandler('updater:dismissAvailableUpdate') + ipcMain.removeHandler('updater:listBuilds') ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) @@ -537,4 +542,19 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) ipcMain.handle('updater:dismissNudge', () => dismissNudge()) ipcMain.handle('updater:dismissAvailableUpdate', () => dismissAvailableUpdate()) + ipcMain.handle( + 'updater:listBuilds', + async (_event, channel: ReleaseChannel): Promise => { + if (!RELEASE_CHANNELS.includes(channel)) { + return { ok: false, channel, message: `Unknown release channel "${channel}".` } + } + try { + return { ok: true, channel, builds: await listAvailableReleaseBuilds(channel) } + } catch (error) { + // Why: a network/rate-limit failure is expected here; return it as data so + // the picker can render the reason instead of rejecting the invoke. + return { ok: false, channel, message: String((error as Error)?.message ?? error) } + } + } + ) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 81796d17eb3..cdb5000d6cc 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -28,6 +28,7 @@ import type { } from '../shared/local-log-tail-types' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { ReleaseChannel } from '../shared/release-channel' import type { HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, @@ -255,6 +256,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + ReleaseBuildListResult, UpdateCheckOptions, UpdateStatus, Worktree, @@ -2672,6 +2674,7 @@ export type PreloadApi = { quitAndInstall: () => Promise dismissNudge: () => Promise dismissAvailableUpdate: () => Promise + listBuilds: (channel: ReleaseChannel) => Promise onStatus: (callback: (status: UpdateStatus) => void) => () => void onClearDismissal: (callback: () => void) => () => void } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c63fd9756d..9da862f485b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2952,6 +2952,7 @@ const api = { download: () => ipcRenderer.invoke('updater:download'), dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), + listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), quitAndInstall: async (): Promise => { await prepareRendererForAppRestart(window, { startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT, diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index b9e6635f36e..7095ab97163 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -9,6 +9,7 @@ import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' import { GeneralRemoteServerUpdates } from './GeneralRemoteServerUpdates' +import { ReleaseChannelSection } from './ReleaseChannelSection' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -39,6 +40,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { const [appVersion, setAppVersion] = useState(null) const updateCheckHint = getUpdateCheckHint() + // Why: channel switching is a power-user escape hatch that can downgrade the app + // onto an unvetted build. Option/Alt-clicking the header reveals it, matching the + // Help menu's hidden admin options rather than shipping it on the default surface. + const [channelSwitcherRevealed, setChannelSwitcherRevealed] = useState(false) useEffect(() => { let cancelled = false @@ -62,17 +67,25 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { return (
    - +
    { + if (event.altKey) { + setChannelSwitcherRevealed((revealed) => !revealed) + } + }} + > + +
    + {channelSwitcherRevealed ? : null}
    ) diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx new file mode 100644 index 00000000000..368ded2a0d7 --- /dev/null +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -0,0 +1,281 @@ +import type React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '../../store' +import { Button } from '../ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' +import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' +import { Badge } from '../ui/badge' +import { translate } from '@/i18n/i18n' +import { + RELEASE_CHANNELS, + getVersionChannel, + parseHourlyVersionStamp, + type ReleaseBuild, + type ReleaseChannel +} from '../../../../shared/release-channel' + +const CHANNEL_LABELS: Record = { + stable: 'Stable', + rc: 'RC', + hourly: 'Hourly' +} + +const CHANNEL_DESCRIPTIONS: Record = { + stable: 'Shipped releases. What everyone else is running.', + rc: 'Release candidates cut ahead of each stable.', + hourly: 'Unvetted macOS builds from main, built every hour. No tests, no notarization.' +} + +function formatBuildLabel(build: ReleaseBuild): string { + const stamp = parseHourlyVersionStamp(build.version) + if (!stamp) { + return build.version + } + // Why: an hourly's semver tail is an opaque timestamp; show it as local time so + // "which build was that" is answerable at a glance. + return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + })}` +} + +export function ReleaseChannelSection(): React.JSX.Element { + const updateStatus = useAppStore((s) => s.updateStatus) + const releaseChannelOverride = useAppStore((s) => s.releaseChannelOverride) + const setReleaseChannelOverride = useAppStore((s) => s.setReleaseChannelOverride) + + const [appVersion, setAppVersion] = useState(null) + const [builds, setBuilds] = useState(null) + const [loadError, setLoadError] = useState(null) + const [loading, setLoading] = useState(false) + const [selectedTag, setSelectedTag] = useState(null) + + const runningChannel = appVersion ? getVersionChannel(appVersion) : null + const activeChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + const busy = updateStatus.state === 'checking' || updateStatus.state === 'downloading' + + useEffect(() => { + let cancelled = false + void window.api.updater.getVersion().then((version) => { + if (!cancelled) { + setAppVersion(version) + } + }) + return () => { + cancelled = true + } + }, []) + + // Why: two loads can be in flight at once — activeChannel flips on mount when + // getVersion resolves, and rapid channel clicks stack requests. Without this + // guard a slower earlier request can land last and fill the list with builds + // from a channel the picker is no longer showing. + const latestRequestRef = useRef(0) + + const loadBuilds = useCallback(async (channel: ReleaseChannel): Promise => { + const requestId = latestRequestRef.current + 1 + latestRequestRef.current = requestId + const isStale = (): boolean => latestRequestRef.current !== requestId + setLoading(true) + setLoadError(null) + try { + const result = await window.api.updater.listBuilds(channel) + if (isStale()) { + return + } + if (result.ok) { + setBuilds(result.builds) + setSelectedTag(result.builds[0]?.tag ?? null) + } else { + setBuilds(null) + setLoadError(result.message) + } + } catch (error) { + if (isStale()) { + return + } + setBuilds(null) + setLoadError(String((error as Error)?.message ?? error)) + } finally { + // Why: only the newest request owns the spinner; a superseded one clearing + // it would show "no builds" while the current load is still running. + if (!isStale()) { + setLoading(false) + } + } + }, []) + + // Why: reload whenever the channel changes so the picker never offers tags + // from the channel the user just switched away from. + useEffect(() => { + setBuilds(null) + setSelectedTag(null) + void loadBuilds(activeChannel) + }, [activeChannel, loadBuilds]) + + const selectedBuild = useMemo( + () => builds?.find((build) => build.tag === selectedTag) ?? null, + [builds, selectedTag] + ) + + const handleSwitchTo = (build: ReleaseBuild): void => { + void window.api.updater + .check({ channel: build.channel, targetTag: build.tag }) + .catch((error) => { + toast.error( + translate( + 'auto.components.settings.ReleaseChannelSection.switchFailed', + 'Could not switch to that build.' + ), + { description: String((error as Error)?.message ?? error) } + ) + }) + } + + const isRunningBuild = selectedBuild?.version === appVersion + + return ( +
    +
    + + + {translate('auto.components.settings.ReleaseChannelSection.devOnly', 'Dev only')} + +
    + +
    + + value={activeChannel} + // Why: selecting the running build's own channel clears the override + // rather than pinning it. Without this there is no way back to "follow + // this build's channel", so a dev who merely looked at the panel would + // leave background checks pinned to whatever they last clicked. + onChange={(channel) => + setReleaseChannelOverride(channel === runningChannel ? null : channel) + } + ariaLabel={translate( + 'auto.components.settings.ReleaseChannelSection.channelAriaLabel', + 'Update channel' + )} + options={RELEASE_CHANNELS.map((channel) => ({ + value: channel, + label: CHANNEL_LABELS[channel] + }))} + /> +

    {CHANNEL_DESCRIPTIONS[activeChannel]}

    +
    + + {activeChannel === 'hourly' ? ( +
    + +

    + {translate( + 'auto.components.settings.ReleaseChannelSection.hourlyWarning', + 'Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.' + )} +

    +
    + ) : null} + +
    +
    + + + + + +
    + + {loadError ? ( +

    {loadError}

    + ) : isRunningBuild ? ( +

    + {translate( + 'auto.components.settings.ReleaseChannelSection.alreadyRunning', + 'This is the build you are running.' + )} +

    + ) : selectedBuild ? ( +

    + {translate( + 'auto.components.settings.ReleaseChannelSection.willSwitch', + '{{value0}} → {{value1}}', + { value0: appVersion ?? '…', value1: selectedBuild.version } + )} +

    + ) : null} +
    +
    + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 76557b35550..e3c586a4d81 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10075,6 +10075,21 @@ "setupRecipe": "Set up an environment recipe for your cloud provider.", "createWorkspace": "Create a workspace and select that recipe under Run on.", "openSetup": "Set up environment recipes" + }, + "ReleaseChannelSection": { + "switchFailed": "Could not switch to that build.", + "title": "Release channel", + "description": "Switch update channels or jump to any published build, including older ones. Downgrades are allowed and unvetted builds can be broken.", + "devOnly": "Dev only", + "channelAriaLabel": "Update channel", + "hourlyWarning": "Hourly builds are macOS-only, ship straight from main with no test gate, and are signed but not notarized. Keep a stable build handy.", + "loadingBuilds": "Loading builds…", + "noBuilds": "No builds found", + "refresh": "Refresh build list", + "switchTo": "Switch to build", + "alreadyRunning": "This is the build you are running.", + "willSwitch": "{{value0}} → {{value1}}", + "webUnavailable": "Switching builds is only available in the desktop app." } }, "right": { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 02402740316..625a8e9aba0 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -36,6 +36,7 @@ import { normalizeManualRepoOrder } from '../../../../shared/manual-repo-order' import { isTopLevelView } from '../../../../shared/top-level-view' +import { isReleaseChannel, type ReleaseChannel } from '../../../../shared/release-channel' import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY, @@ -969,6 +970,9 @@ export type UISlice = { dismissedUpdateVersion: string | null dismissUpdate: (versionOverride?: string) => void clearDismissedUpdateVersion: () => void + /** Dev-only channel override; null follows the running build's own channel. */ + releaseChannelOverride: ReleaseChannel | null + setReleaseChannelOverride: (channel: ReleaseChannel | null) => void // Why: ephemeral, renderer-only — never persisted; resets each session and on every phase transition (see setUpdateStatus). updateCardCollapsed: boolean setUpdateCardCollapsed: (collapsed: boolean) => void @@ -2494,6 +2498,12 @@ export const createUISlice: StateCreator = (set, get) return DEFAULT_PET_ID })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, + // Why: a persisted value from a build that knew a different channel set + // would otherwise survive as-is; activeChannel only falls back on null, + // so an unknown string reaches listBuilds and the segmented control. + releaseChannelOverride: isReleaseChannel(ui.releaseChannelOverride) + ? ui.releaseChannelOverride + : null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, browserDefaultUrl: ui.browserDefaultUrl ?? null, @@ -2588,6 +2598,11 @@ export const createUISlice: StateCreator = (set, get) clearDismissedUpdateVersion: () => { set({ dismissedUpdateVersion: null }) }, + releaseChannelOverride: null, + setReleaseChannelOverride: (channel) => { + void window.api.ui.set({ releaseChannelOverride: channel }).catch(console.error) + set({ releaseChannelOverride: channel }) + }, dismissUpdate: (versionOverride?: string) => set((s) => { // Why: the 'error' variant has no version field, so the card passes it via versionOverride. diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2ae7a4d50c3..6e6367a5afa 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3070,6 +3070,17 @@ function createUpdaterApi(): NonNullable['updater']> { quitAndInstall: () => Promise.resolve(), dismissNudge: () => Promise.resolve(), dismissAvailableUpdate: () => Promise.resolve(), + // Why: the web client cannot install a desktop build, so channel switching + // reports unavailable rather than an empty list that looks like a fetch miss. + listBuilds: (channel) => + Promise.resolve({ + ok: false, + channel, + message: translate( + 'auto.components.settings.ReleaseChannelSection.webUnavailable', + 'Switching builds is only available in the desktop app.' + ) + }), onStatus: () => noopUnsubscribe, onClearDismissal: () => noopUnsubscribe } diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts new file mode 100644 index 00000000000..c02ffe888c6 --- /dev/null +++ b/src/shared/release-channel.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { + formatHourlyVersion, + getReleaseRepoForChannel, + getVersionChannel, + isHourlyVersion, + isReleaseChannel, + parseHourlyVersionStamp, + sortReleaseBuildsNewestFirst, + type ReleaseBuild +} from './release-channel' +import { compareAppVersions } from './app-version' + +describe('release channel', () => { + it('classifies versions by channel', () => { + expect(getVersionChannel('1.4.160')).toBe('stable') + expect(getVersionChannel('v1.4.160')).toBe('stable') + expect(getVersionChannel('1.4.160-rc.3')).toBe('rc') + expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly') + expect(getVersionChannel('not-a-version')).toBeNull() + }) + + // Why: hourly tags must never resolve to the main repo — the releases atom feed + // exposes only 10 entries, so 24 hourly tags a day would evict every stable/RC + // entry and leave real users with nothing to update to. + it('keeps hourly builds out of the main release repo', () => { + expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly') + expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca') + expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca') + }) + + it('round-trips an hourly version stamp as UTC', () => { + const version = formatHourlyVersion('1.4.160', '202607281405') + expect(isHourlyVersion(version)).toBe(true) + expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z') + }) + + it('rejects malformed hourly identifiers', () => { + expect(isHourlyVersion('1.4.160-hourly')).toBe(false) + expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false) + expect(isHourlyVersion('1.4.160-rc.3')).toBe(false) + expect(parseHourlyVersionStamp('1.4.160-rc.3')).toBeNull() + }) + + // Why: an unanchored tail match also accepted garbage prefixes, and Date.UTC + // rolls impossible dates forward, so `...hourly.202602300000` rendered as + // March 2 rather than being rejected. + it('rejects a bad base version and impossible calendar stamps', () => { + expect(parseHourlyVersionStamp('not-a-version-hourly.202601010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4-hourly.202601010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202602300000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202613010000')).toBeNull() + expect(parseHourlyVersionStamp('1.4.160-hourly.202601012500')).toBeNull() + // Leap day 2028 is real and must still parse. + expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe( + '2028-02-29T00:00:00.000Z' + ) + }) + + it('accepts only known channels', () => { + expect(isReleaseChannel('hourly')).toBe(true) + expect(isReleaseChannel('stable')).toBe(true) + expect(isReleaseChannel('nightly')).toBe(false) + expect(isReleaseChannel(null)).toBe(false) + expect(isReleaseChannel(undefined)).toBe(false) + }) + + // Why: consecutive hourlies differ only in the timestamp tail, so semver + // ordering must follow the clock or the picker offers them out of order. + it('sorts consecutive hourly builds newest first', () => { + const build = (version: string): ReleaseBuild => ({ + tag: `v${version}`, + version, + channel: 'hourly', + publishedAt: null, + releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}` + }) + const sorted = sortReleaseBuildsNewestFirst([ + build('1.4.160-hourly.202607280900'), + build('1.4.160-hourly.202607281400'), + build('1.4.160-hourly.202607281000') + ]) + expect(sorted.map((entry) => entry.version)).toEqual([ + '1.4.160-hourly.202607281400', + '1.4.160-hourly.202607281000', + '1.4.160-hourly.202607280900' + ]) + }) + + // Why: an hourly is cut from main and must not read as newer than the stable it + // is based on, or stable users would be offered it by an ordinary check. + it('orders an hourly below its own stable release', () => { + expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0) + }) +}) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts new file mode 100644 index 00000000000..5295f444729 --- /dev/null +++ b/src/shared/release-channel.ts @@ -0,0 +1,84 @@ +import { compareAppVersions, isValidAppVersion } from './app-version' + +export type ReleaseChannel = 'stable' | 'rc' | 'hourly' + +export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly'] + +/** Hourly builds live in their own repo so their tags never enter the main + * releases atom feed, which only exposes the 10 newest entries — 24 hourly + * tags a day would evict every stable/RC entry and strand real users. */ +export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly' +export const MAIN_RELEASE_REPO = 'stablyai/orca' + +export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly' + +export function isReleaseChannel(value: unknown): value is ReleaseChannel { + return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel) +} + +export function getReleaseRepoForChannel(channel: ReleaseChannel): string { + return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO +} + +export function normalizeTagToVersion(tag: string): string { + return tag.replace(/^v/i, '') +} + +/** `1.4.160-hourly.202607281400` — a timestamp identifier keeps every build + * uniquely versioned so electron-updater never reads one as "same version". */ +export function isHourlyVersion(version: string): boolean { + return /^\d+\.\d+\.\d+-hourly\.\d{12}$/.test(normalizeTagToVersion(version)) +} + +export function formatHourlyVersion(baseVersion: string, stamp: string): string { + return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}` +} + +/** Returns the build's UTC timestamp, or null when the version isn't hourly. */ +export function parseHourlyVersionStamp(version: string): Date | null { + const normalized = normalizeTagToVersion(version) + // Why anchored on the whole version: an unanchored tail match also accepts + // garbage prefixes, so `not-a-version-hourly.202601010000` would parse. + const match = normalized.match(/^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/) + if (!match) { + return null + } + const [year, month, day, hour, minute] = match.slice(1).map(Number) + const parsed = new Date(Date.UTC(year, month - 1, day, hour, minute)) + // Why the round-trip: Date.UTC silently rolls impossible dates forward, so a + // corrupt `...hourly.202602300000` would render as March 2 rather than fail. + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day || + parsed.getUTCHours() !== hour || + parsed.getUTCMinutes() !== minute + ) { + return null + } + return parsed +} + +export function getVersionChannel(version: string): ReleaseChannel | null { + const normalized = normalizeTagToVersion(version) + if (!isValidAppVersion(normalized)) { + return null + } + if (isHourlyVersion(normalized)) { + return 'hourly' + } + return normalized.includes('-') ? 'rc' : 'stable' +} + +export type ReleaseBuild = { + tag: string + version: string + channel: ReleaseChannel + publishedAt: string | null + releaseUrl: string +} + +/** Newest first, so the picker's first row is always the channel's current tip. */ +export function sortReleaseBuildsNewestFirst(builds: ReleaseBuild[]): ReleaseBuild[] { + return [...builds].sort((left, right) => compareAppVersions(right.version, left.version)) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b8acdc76d4b..fa6ca7b9f91 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from './execution-host' import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' +import type { ReleaseBuild, ReleaseChannel } from './release-channel' import type { GitHubProjectSettings } from './github-project-types' import type { AgentStatusState, @@ -2373,9 +2374,12 @@ export type UpdateCheckOptions = { includePrerelease?: boolean includePerfPrerelease?: boolean localBuild?: boolean + /** Dev channel switching; `targetTag` pins an exact build, including older ones. */ + channel?: ReleaseChannel + targetTag?: string } -export type UpdateSource = 'local' +export type UpdateSource = 'local' | 'hourly' export type UpdateStatus = ( | { state: 'idle' } @@ -2402,6 +2406,10 @@ export type UpdateStatus = ( | { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string } ) & { source?: UpdateSource } +export type ReleaseBuildListResult = + | { ok: true; channel: ReleaseChannel; builds: ReleaseBuild[] } + | { ok: false; channel: ReleaseChannel; message: string } + // ─── Settings ──────────────────────────────────────────────────────── export type NotificationSettings = { enabled: boolean @@ -3377,6 +3385,8 @@ export type PersistedUIState = { statusBarUsageMode?: StatusBarUsageMode dismissedUpdateVersion: string | null lastUpdateCheckAt: number | null + /** Dev-only update channel override; absent means the build's own channel. */ + releaseChannelOverride?: ReleaseChannel | null pendingUpdateNudgeId?: string | null dismissedUpdateNudgeId?: string | null /** Whether Orca already tried triggering the macOS notification permission dialog; prevents re-firing every launch. */ From d5c4d953ec48386b270baf94cbc914d38792a341 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:55:48 -0700 Subject: [PATCH 049/123] perf: coalesce cancellable git status reads (#11691) --- .../git/git-status-read-lease-owner.test.ts | 154 +++++++++++++ src/main/git/git-status-read-lease-owner.ts | 101 +++++++++ src/main/git/status.test.ts | 204 +++++++++++++++++- src/main/git/status.ts | 31 +-- 4 files changed, 461 insertions(+), 29 deletions(-) create mode 100644 src/main/git/git-status-read-lease-owner.test.ts create mode 100644 src/main/git/git-status-read-lease-owner.ts diff --git a/src/main/git/git-status-read-lease-owner.test.ts b/src/main/git/git-status-read-lease-owner.test.ts new file mode 100644 index 00000000000..c6ae5649690 --- /dev/null +++ b/src/main/git/git-status-read-lease-owner.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + +describe('GitStatusReadLeaseOwner', () => { + it('cancels first and later callers without aborting a remaining lease', async () => { + const owner = new GitStatusReadLeaseOwner() + const pending = deferred() + const load = vi.fn((_signal: AbortSignal) => pending.promise) + const firstController = new AbortController() + const secondController = new AbortController() + const thirdController = new AbortController() + const firstError = new Error('first cancelled') + const thirdError = new Error('third cancelled') + + const first = owner.lease('repo', firstController.signal, load) + const second = owner.lease('repo', secondController.signal, load) + const third = owner.lease('repo', thirdController.signal, load) + firstController.abort(firstError) + thirdController.abort(thirdError) + + await expect(first).rejects.toBe(firstError) + await expect(third).rejects.toBe(thirdError) + expect(load).toHaveBeenCalledOnce() + expect(load.mock.calls[0][0].aborted).toBe(false) + + pending.resolve('fresh') + await expect(second).resolves.toBe('fresh') + }) + + it('aborts underlying work when every live lease cancels and admits a fresh read', async () => { + const owner = new GitStatusReadLeaseOwner() + const firstPending = deferred() + const secondPending = deferred() + const signals: AbortSignal[] = [] + const load = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return signals.length === 1 ? firstPending.promise : secondPending.promise + }) + const firstController = new AbortController() + const secondController = new AbortController() + + const first = owner.lease('repo', firstController.signal, load) + const second = owner.lease('repo', secondController.signal, load) + firstController.abort(new Error('first cancelled')) + expect(signals[0]?.aborted).toBe(false) + secondController.abort(new Error('second cancelled')) + + await expect(first).rejects.toThrow('first cancelled') + await expect(second).rejects.toThrow('second cancelled') + expect(signals[0]?.aborted).toBe(true) + + const fresh = owner.lease('repo', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + secondPending.resolve('fresh') + await expect(fresh).resolves.toBe('fresh') + firstPending.reject(new Error('underlying aborted')) + await Promise.resolve() + }) + + it('rejects a pre-aborted caller without starting or joining work', async () => { + const owner = new GitStatusReadLeaseOwner() + const pending = deferred() + const load = vi.fn(() => pending.promise) + const active = owner.lease('repo', undefined, load) + const controller = new AbortController() + const abortError = new Error('already cancelled') + controller.abort(abortError) + + await expect(owner.lease('repo', controller.signal, load)).rejects.toBe(abortError) + expect(load).toHaveBeenCalledOnce() + + pending.resolve('active') + await expect(active).resolves.toBe('active') + }) + + it('cleans listeners and entries after success, failure, and cancellation', async () => { + const owner = new GitStatusReadLeaseOwner() + const successController = new AbortController() + const successRemove = vi.spyOn(successController.signal, 'removeEventListener') + await expect( + owner.lease('success', successController.signal, async () => 'success') + ).resolves.toBe('success') + expect(successRemove).toHaveBeenCalledOnce() + + const failureController = new AbortController() + const failureRemove = vi.spyOn(failureController.signal, 'removeEventListener') + const failure = new Error('failed') + await expect( + owner.lease('failure', failureController.signal, async () => { + throw failure + }) + ).rejects.toBe(failure) + expect(failureRemove).toHaveBeenCalledOnce() + + const cancelController = new AbortController() + const cancelRemove = vi.spyOn(cancelController.signal, 'removeEventListener') + const cancelPending = deferred() + const cancelled = owner.lease('cancel', cancelController.signal, () => cancelPending.promise) + cancelController.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(cancelRemove).toHaveBeenCalledOnce() + + const freshLoad = vi.fn(async () => 'fresh') + await expect(owner.lease('success', undefined, freshLoad)).resolves.toBe('fresh') + await expect(owner.lease('failure', undefined, freshLoad)).resolves.toBe('fresh') + await expect(owner.lease('cancel', undefined, freshLoad)).resolves.toBe('fresh') + expect(freshLoad).toHaveBeenCalledTimes(3) + cancelPending.reject(new Error('underlying aborted')) + await Promise.resolve() + }) + + it('invalidates joinability without breaking already-issued leases', async () => { + const owner = new GitStatusReadLeaseOwner() + const firstPending = deferred() + const secondPending = deferred() + const load = vi + .fn<(_signal: AbortSignal) => Promise>() + .mockReturnValueOnce(firstPending.promise) + .mockReturnValueOnce(secondPending.promise) + + const first = owner.lease('repo', undefined, load) + owner.invalidate() + const second = owner.lease('repo', undefined, load) + expect(load).toHaveBeenCalledTimes(2) + + firstPending.resolve('pre-mutation') + secondPending.resolve('post-mutation') + await expect(first).resolves.toBe('pre-mutation') + await expect(second).resolves.toBe('post-mutation') + }) + + it('never caches a settled read', async () => { + const owner = new GitStatusReadLeaseOwner() + const load = vi.fn(async () => `read-${load.mock.calls.length}`) + + await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-1') + await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-2') + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts new file mode 100644 index 00000000000..a0012da7d3a --- /dev/null +++ b/src/main/git/git-status-read-lease-owner.ts @@ -0,0 +1,101 @@ +type StatusReadEntry = { + controller: AbortController + promise: Promise + liveLeases: number + settled: boolean +} + +function getAbortReason(signal: AbortSignal): unknown { + try { + signal.throwIfAborted() + } catch (error) { + return error + } + return new DOMException('This operation was aborted', 'AbortError') +} + +export class GitStatusReadLeaseOwner { + private readonly entries = new Map>() + + lease( + key: string, + signal: AbortSignal | undefined, + load: (sharedSignal: AbortSignal) => Promise + ): Promise { + if (signal?.aborted) { + return Promise.reject(getAbortReason(signal)) + } + + let entry = this.entries.get(key) + if (!entry) { + const controller = new AbortController() + const promise = load(controller.signal) + const createdEntry = { controller, promise, liveLeases: 0, settled: false } + entry = createdEntry + this.entries.set(key, createdEntry) + void promise.then( + () => this.settle(key, createdEntry), + () => this.settle(key, createdEntry) + ) + } + + entry.liveLeases += 1 + return this.createLease(key, entry, signal) + } + + invalidate(): void { + this.entries.clear() + } + + private createLease( + key: string, + entry: StatusReadEntry, + signal: AbortSignal | undefined + ): Promise { + return new Promise((resolve, reject) => { + let active = true + const release = (abortReason?: unknown): boolean => { + if (!active) { + return false + } + active = false + signal?.removeEventListener('abort', onAbort) + entry.liveLeases -= 1 + if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + entry.controller.abort(abortReason) + } + return true + } + const onAbort = (): void => { + const reason = getAbortReason(signal!) + if (release(reason)) { + reject(reason) + } + } + + signal?.addEventListener('abort', onAbort, { once: true }) + void entry.promise.then( + (value) => { + if (release()) { + resolve(value) + } + }, + (error: unknown) => { + if (release()) { + reject(error) + } + } + ) + }) + } + + private settle(key: string, entry: StatusReadEntry): void { + entry.settled = true + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + } +} diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index abe618a327a..4bd645c7141 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -11,6 +11,7 @@ import { const { gitExecFileAsyncMock, gitExecFileAsyncBufferMock, + gitStreamOptionsMock, lstatMock, realpathMock, readFileMock, @@ -20,6 +21,7 @@ const { } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), + gitStreamOptionsMock: vi.fn(), lstatMock: vi.fn(), realpathMock: vi.fn(), readFileMock: vi.fn(), @@ -37,10 +39,11 @@ vi.mock('./runner', () => ({ // keep working unchanged and call ordering (status, then numstat) is preserved. gitStreamStdout: async ( args: string[], - options: { onStdout: (chunk: string) => boolean | void } + options: { signal?: AbortSignal; onStdout: (chunk: string) => boolean | void } ) => { // Forward args so arg-routing mock implementations (e.g. `args.includes`) // still match the status read. + gitStreamOptionsMock(options) const { stdout } = await gitExecFileAsyncMock(args) const stoppedEarly = options.onStdout(stdout ?? '') === true return { stoppedEarly } @@ -991,6 +994,7 @@ describe('getSubmoduleStatus', () => { clearEffectiveUpstreamStatusCacheForTests() gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + gitStreamOptionsMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() @@ -1105,6 +1109,7 @@ describe('getStatus', () => { clearEffectiveUpstreamStatusCacheForTests() gitExecFileAsyncMock.mockReset() gitExecFileAsyncBufferMock.mockReset() + gitStreamOptionsMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() existsSyncMock.mockReset() @@ -1133,12 +1138,22 @@ describe('getStatus', () => { return Promise.resolve({ stdout: '' }) }) + const runBurst = async (withSignals: boolean): Promise => { + gitExecFileAsyncMock.mockClear() + await Promise.all( + Array.from({ length: 10 }, () => + getStatus('/repo', withSignals ? { signal: new AbortController().signal } : {}) + ) + ) + return gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('status') + ).length + } + const startedAt = performance.now() - await Promise.all(Array.from({ length: 10 }, () => getStatus('/repo'))) + const unsignalledStatusCommandCalls = await runBurst(false) + const signalledStatusCommandCalls = await runBurst(true) const durationMs = performance.now() - startedAt - const statusCommandCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => - (args as string[]).includes('status') - ).length const { mkdirSync, writeFileSync } = await vi.importActual('fs') mkdirSync(path.dirname(benchPath), { recursive: true }) writeFileSync( @@ -1146,7 +1161,16 @@ describe('getStatus', () => { JSON.stringify({ scenario: 'git-status-concurrent-burst', concurrentCalls: 10, - statusCommandCalls, + unsignalledStatusCommandCalls, + signalledStatusCommandCalls, + statusArgs: [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ], durationMs }) ) @@ -1183,6 +1207,133 @@ describe('getStatus', () => { expect(statusCommandCalls).toBe(2) }) + it('shares one physical status read across distinct caller signals', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let releaseStatus!: () => void + let statusCommandCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatus = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const firstController = new AbortController() + const secondController = new AbortController() + const firstError = new Error('first caller cancelled') + const first = getStatus('/repo', { signal: firstController.signal }) + const second = getStatus('/repo', { signal: secondController.signal }) + + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const underlyingSignal = gitStreamOptionsMock.mock.calls[0]?.[0].signal as AbortSignal + firstController.abort(firstError) + await expect(first).rejects.toBe(firstError) + expect(underlyingSignal?.aborted).toBe(false) + + releaseStatus() + await expect(second).resolves.toMatchObject({ entries: [] }) + expect(statusCommandCalls).toBe(1) + }) + + it('aborts physical status work after its last live caller cancels', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + let rejectStatus!: (error: unknown) => void + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (!args.includes('status')) { + return Promise.resolve({ stdout: '' }) + } + statusCommandCalls += 1 + if (statusCommandCalls > 1) { + return Promise.resolve({ stdout: '' }) + } + return new Promise<{ stdout: string }>((_resolve, reject) => { + rejectStatus = reject + }) + }) + const firstController = new AbortController() + const secondController = new AbortController() + const first = getStatus('/repo', { signal: firstController.signal }) + const second = getStatus('/repo', { signal: secondController.signal }) + + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const underlyingSignal = gitStreamOptionsMock.mock.calls[0]?.[0].signal as AbortSignal + underlyingSignal.addEventListener('abort', () => rejectStatus(underlyingSignal.reason), { + once: true + }) + firstController.abort(new Error('first cancelled')) + await expect(first).rejects.toThrow('first cancelled') + expect(underlyingSignal.aborted).toBe(false) + secondController.abort(new Error('second cancelled')) + await expect(second).rejects.toThrow('second cancelled') + expect(underlyingSignal.aborted).toBe(true) + + await expect(getStatus('/repo')).resolves.toMatchObject({ entries: [] }) + expect(statusCommandCalls).toBe(2) + }) + + it('rejects a pre-aborted caller without starting or joining status work', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let releaseStatus!: () => void + let statusCommandCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatus = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const active = getStatus('/repo') + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const controller = new AbortController() + const abortError = new Error('already cancelled') + controller.abort(abortError) + + await expect(getStatus('/repo', { signal: controller.signal })).rejects.toBe(abortError) + expect(statusCommandCalls).toBe(1) + + releaseStatus() + await active + }) + + it('isolates status reads by worktree, host, and output-affecting options', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + const releases: (() => void)[] = [] + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releases.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + const reads = [ + getStatus('/repo'), + getStatus('/other-repo'), + getStatus('/repo', { wslDistro: 'Ubuntu' }), + getStatus('/repo', { includeIgnored: true }), + getStatus('/repo', { reuseLineStats: true }), + getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }), + getStatus('/repo', { limit: 1 }), + getStatus('/repo', { sharedLinkPaths: ['node_modules'] }) + ] + + await vi.waitFor(() => expect(statusCommandCalls).toBe(8)) + releases.splice(0).forEach((release) => release()) + await Promise.all(reads) + expect(statusCommandCalls).toBe(8) + }) + it('clears in-flight status reads when a mutation runs', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockReturnValue(false) @@ -1213,6 +1364,42 @@ describe('getStatus', () => { expect(statusCommandCalls).toBe(2) }) + it('fences reads started before, during, and after a concurrent mutation', async () => { + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + existsSyncMock.mockReturnValue(false) + let statusCommandCalls = 0 + let releaseMutation!: () => void + const releaseStatusReads: (() => void)[] = [] + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('status')) { + statusCommandCalls += 1 + return new Promise<{ stdout: string }>((resolve) => { + releaseStatusReads.push(() => resolve({ stdout: '' })) + }) + } + if (args.includes('add')) { + return new Promise<{ stdout: string }>((resolve) => { + releaseMutation = () => resolve({ stdout: '' }) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const beforeMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(1)) + const mutation = stageFile('/repo', 'src/file.ts') + const duringMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(2)) + releaseMutation() + await mutation + const afterMutation = getStatus('/repo', { signal: new AbortController().signal }) + await vi.waitFor(() => expect(statusCommandCalls).toBe(3)) + + releaseStatusReads.splice(0).forEach((release) => release()) + await Promise.all([beforeMutation, duringMutation, afterMutation]) + expect(statusCommandCalls).toBe(3) + }) + it('parses unmerged porcelain v2 entries into unresolved conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') existsSyncMock.mockImplementation((target: string) => target.endsWith('MERGE_HEAD')) @@ -1631,7 +1818,10 @@ describe('getStatus', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'], - { cwd: '/repo', env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) } + expect.objectContaining({ + cwd: '/repo', + env: expect.objectContaining({ GIT_OPTIONAL_LOCKS: '0' }) + }) ) expect(result.entries).toEqual([ { path: 'docs/a => b.txt', status: 'modified', area: 'unstaged', added: 1, removed: 0 } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 98d04ec93b9..120c84869d2 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -52,6 +52,7 @@ import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' import { beginGitStatusLineStatsCacheWrite, @@ -92,12 +93,12 @@ const effectiveUpstreamStatusInFlight = new Map>() const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() -const statusReadsInFlight = new Map>() +const statusReadLeaseOwner = new GitStatusReadLeaseOwner() // Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() - statusReadsInFlight.clear() + statusReadLeaseOwner.invalidate() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -216,31 +217,17 @@ export async function getStatus( options: GetStatusOptions = {} ): Promise { gitDiffReadDedupe.clear() - if (options.signal) { - return runGetStatus(worktreePath, options) - } // Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read. const cacheKey = getStatusReadKey(worktreePath, options) - const inFlightStatus = statusReadsInFlight.get(cacheKey) - if (inFlightStatus) { - return inFlightStatus - } - - const statusPromise = runGetStatus(worktreePath, options) - statusReadsInFlight.set(cacheKey, statusPromise) - try { - return await statusPromise - } finally { - if (statusReadsInFlight.get(cacheKey) === statusPromise) { - statusReadsInFlight.delete(cacheKey) - } - } + return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) => + runGetStatus(worktreePath, { ...options, signal: sharedSignal }) + ) } function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string { // Why: each key part can change the output shape or runtime routing. const limit = resolveGitStatusLimit(options.limit) - return [ + return stableInFlightKey([ worktreePath, options.wslDistro ?? '', options.includeIgnored === true, @@ -248,8 +235,8 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. - (options.sharedLinkPaths ?? []).join('\u0001') - ].join('\0') + options.sharedLinkPaths ?? [] + ]) } /** Remove untracked entries that are shared symlinks Orca created. From aa6f945001a5f78a07663c854fabb95c55d8b40b Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 30 Jul 2026 22:59:32 -0700 Subject: [PATCH 050/123] fix(terminal): serialize startup context with user paste (#11612) * fix(agents): prevent startup paste from submitting user input * fix(terminal): cancel startup submit on middle-click paste * fix(terminal): cancel startup context on first user input * fix(terminal): record activity only for accepted paste * fix(terminal): serialize bracketed paste operations * Revert "fix(terminal): cancel startup context on first user input" This reverts commit 5459ce795706d2ad7d864ad69c81bbf9b2ba1768. * Revert "fix(agents): prevent startup paste from submitting user input" This reverts commit affbf333e26226ad9d509e64ac351d00b9477ccb. * Revert "fix(terminal): cancel startup submit on middle-click paste" This reverts commit 6775751aa86850fac1355bac14183a013fac1847. * test(terminal): cover startup and user paste ordering * perf(terminal): keep uncontended paste delivery synchronous --- .../terminal-pane/pty-connection.test.ts | 8 +- .../terminal-pane/terminal-paste-executor.ts | 10 ++ .../terminal-paste-operation-order.test.ts | 152 ++++++++++++++++++ .../terminal-pty-input-transaction.ts | 29 ++++ .../src/lib/agent-draft-paste-content.ts | 12 ++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 775251943ce..e80c19f1a1f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -6415,7 +6415,7 @@ describe('connectPanePty', () => { } }) - it('orders a startup draft behind xterm focus input when Codex renders its composer', async () => { + it('orders a startup draft behind existing user input when Codex renders its composer', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -6454,6 +6454,11 @@ describe('connectPanePty', () => { mock: { calls: [(data: string) => void][] } } ).mock.calls[0]?.[0]('\x1b[I') + ;( + pane.terminal.onData as unknown as { + mock: { calls: [(data: string) => void][] } + } + ).mock.calls[0]?.[0]('USER_DRAFT') ;(mockStoreState.recordTerminalInput as ReturnType).mockClear() capturedDataCallback.current?.('\x1b[?2004h\x1b[2K› ') await flushAsyncTicks() @@ -6463,6 +6468,7 @@ describe('connectPanePty', () => { ) expect(transport.sendInput.mock.calls.map(([data]) => data)).toEqual([ '\x1b[I', + 'USER_DRAFT', '\x1b[200~https://github.com/stablyai/orca/issues/42\x1b[201~' ]) expect(window.api.pty.writeAccepted).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index ce3d14c9cf8..f194f71f171 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -6,6 +6,7 @@ import { TERMINAL_REMOTE_PASTE_OPERATION_TIMEOUT_MS } from './terminal-paste-limits' import { runTerminalPasteOperationWithTimeout } from './terminal-paste-operation-timeout' +import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' import type { TerminalPasteExecutionReason, TerminalPasteExecutionResult, @@ -24,6 +25,15 @@ type ExecuteTerminalPastePlanArgs = { } export async function executeTerminalPastePlan( + plan: TerminalPastePlan, + args: ExecuteTerminalPastePlanArgs +): Promise { + return await runTerminalPtyInputTransaction(plan.target.ptyId, () => + executeTerminalPastePlanNow(plan, args) + ) +} + +async function executeTerminalPastePlanNow( plan: TerminalPastePlan, { pasteText, diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts new file mode 100644 index 00000000000..b36f0d06f65 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-paste-operation-order.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' + +import { sendAgentDraftPasteContent } from '@/lib/agent-draft-paste-content' +import { + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, + wrapTerminalBracketedPasteText +} from './terminal-bracketed-paste' +import { executeTerminalPastePlan, planTerminalPaste } from './terminal-paste-coordinator' +import { runTerminalPtyInputTransaction } from './terminal-pty-input-transaction' + +function chunkedWindowsPaste() { + return planTerminalPaste({ + text: 'user-line-1\nuser-line-2', + source: 'keyboard', + target: { + kind: 'terminal', + paneId: 1, + leafId: 'leaf-1', + ptyId: 'pty-1', + runtime: { + platform: 'win32', + runtimeKey: 'local:win32', + kind: 'local', + isWindowsConpty: true + } + }, + terminalBracketedPasteMode: true, + maxDirectBytes: 4, + maxChunkBytes: 12 + }) +} + +describe('terminal paste operation ordering', () => { + it('starts an uncontended paste operation synchronously', async () => { + let started = false + const operation = runTerminalPtyInputTransaction('pty-1', async () => { + started = true + }) + + expect(started).toBe(true) + await operation + }) + + it('keeps startup context outside an active chunked user paste frame', async () => { + const writes: string[] = [] + let startupDraft: Promise | null = null + const writePty = async (data: string): Promise => { + writes.push(data) + return true + } + + const userPasteResult = await executeTerminalPastePlan(chunkedWindowsPaste(), { + pasteText: () => {}, + writePty, + yieldToEventLoop: async () => { + startupDraft ??= sendAgentDraftPasteContent(null, 'pty-1', 'GENERATED_CONTEXT', writePty) + } + }) + await startupDraft + + expect(userPasteResult.status).toBe('pasted') + expect(writes).toEqual([ + BRACKETED_PASTE_START, + 'user-line-1\r', + 'user-line-2', + BRACKETED_PASTE_END, + wrapTerminalBracketedPasteText('GENERATED_CONTEXT') + ]) + }) + + it('waits for an active startup frame before opening a user paste frame', async () => { + const writes: { owner: 'startup' | 'user'; data: string }[] = [] + let releaseStartup!: () => void + let startupOpened!: () => void + const opened = new Promise((resolve) => { + startupOpened = resolve + }) + const startup = sendAgentDraftPasteContent( + null, + 'pty-1', + 'G'.repeat(64 * 1024 + 1), + async (data) => { + writes.push({ owner: 'startup', data }) + if (data === BRACKETED_PASTE_START) { + startupOpened() + await new Promise((resolve) => { + releaseStartup = resolve + }) + } + return true + } + ) + await opened + + const user = executeTerminalPastePlan(chunkedWindowsPaste(), { + pasteText: () => {}, + writePty: async (data) => { + writes.push({ owner: 'user', data }) + return true + }, + yieldToEventLoop: async () => {} + }) + await Promise.resolve() + expect(writes).toEqual([{ owner: 'startup', data: BRACKETED_PASTE_START }]) + + releaseStartup() + await expect(Promise.all([startup, user])).resolves.toEqual([ + true, + expect.objectContaining({ status: 'pasted' }) + ]) + const firstUserWrite = writes.findIndex(({ owner }) => owner === 'user') + expect(firstUserWrite).toBeGreaterThan(0) + expect(writes.slice(0, firstUserWrite).every(({ owner }) => owner === 'startup')).toBe(true) + expect(writes.slice(firstUserWrite).every(({ owner }) => owner === 'user')).toBe(true) + expect(writes[firstUserWrite]?.data).toBe(BRACKETED_PASTE_START) + expect(writes.at(-1)?.data).toBe(BRACKETED_PASTE_END) + }) + + it('does not block paste operations for another PTY', async () => { + let releaseFirst!: () => void + let firstStarted!: () => void + const started = new Promise((resolve) => { + firstStarted = resolve + }) + const first = runTerminalPtyInputTransaction('pty-1', async () => { + firstStarted() + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + await started + + await expect( + runTerminalPtyInputTransaction('pty-2', async () => 'second-complete') + ).resolves.toBe('second-complete') + releaseFirst() + await first + }) + + it('releases a PTY after a failed paste operation', async () => { + await expect( + runTerminalPtyInputTransaction('pty-1', async () => { + throw new Error('write failed') + }) + ).rejects.toThrow('write failed') + + await expect(runTerminalPtyInputTransaction('pty-1', async () => 'recovered')).resolves.toBe( + 'recovered' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts new file mode 100644 index 00000000000..e6c0239751b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-input-transaction.ts @@ -0,0 +1,29 @@ +const transactionTails = new Map>() + +export async function runTerminalPtyInputTransaction( + ptyId: string | null | undefined, + operation: () => Promise +): Promise { + if (!ptyId) { + return await operation() + } + + const previous = transactionTails.get(ptyId) + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + transactionTails.set(ptyId, current) + + if (previous) { + await previous + } + try { + return await operation() + } finally { + release() + if (transactionTails.get(ptyId) === current) { + transactionTails.delete(ptyId) + } + } +} diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 23109db3ba6..3b1865eae6d 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -6,6 +6,7 @@ import { normalizeTerminalPasteLineEndings, wrapTerminalBracketedPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' // Why: bracketed paste markers let supported TUIs treat generated prompt text @@ -25,6 +26,17 @@ export async function sendAgentDraftPasteContent( ptyId: string, content: string, writePty?: AgentDraftPtyInputWriter +): Promise { + return await runTerminalPtyInputTransaction(ptyId, () => + sendAgentDraftPasteContentNow(settings, ptyId, content, writePty) + ) +} + +async function sendAgentDraftPasteContentNow( + settings: Pick | null | undefined, + ptyId: string, + content: string, + writePty?: AgentDraftPtyInputWriter ): Promise { if (content.length > AGENT_DRAFT_PASTE_MAX_BYTES) { return false From 94cf2f1422f30fc309cb47c5e864a831d516fa8b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:17:19 -0700 Subject: [PATCH 051/123] feat(updater): mark the hourly channel macOS-only in the picker (#11708) The hourly workflow builds only macOS artifacts, so on Linux and Windows the channel had nothing to install while still looking selectable. Adds isChannelSupportedOnPlatform in the shared module so the picker and the main-process check cannot drift. The segment is disabled with an Apple glyph and a tooltip rather than hidden, so a dev who has heard of the channel sees that it exists and why it is unavailable instead of not finding it. A persisted 'hourly' override arriving off-macOS (settings sync, or a profile carried from a Mac) now falls back to stable rather than rendering a selected segment the user cannot act on. checkForPinnedBuild rejects the channel too, since IPC is reachable regardless of what the renderer disables. --- src/main/updater.ts | 16 +++++- .../settings/ReleaseChannelSection.tsx | 49 ++++++++++++++++--- src/renderer/src/i18n/locales/en.json | 4 +- src/shared/release-channel.test.ts | 17 +++++++ src/shared/release-channel.ts | 12 +++++ 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/main/updater.ts b/src/main/updater.ts index 838c304a290..1551720b72f 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -45,7 +45,11 @@ import { } from './serve-update-handoff' import type { LocalBuildFeed } from './local-builds/local-build-feed-server' import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { + isChannelSupportedOnPlatform, + type ReleaseBuild, + type ReleaseChannel +} from '../shared/release-channel' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -1524,6 +1528,16 @@ async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promis sendStatus({ state: 'not-available', userInitiated: true }) return } + // Why here as well as in the picker: the renderer disables the option, but IPC + // is reachable regardless, and there is no artifact to install off-macOS. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + sendStatus({ + state: 'error', + message: 'Hourly builds are produced only for macOS.', + userInitiated: true + }) + return + } if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { return } diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 368ded2a0d7..29a7acdfe84 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, Apple, Loader2, RefreshCw } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { Button } from '../ui/button' @@ -8,9 +8,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { SettingsSegmentedControl, SettingsSubsectionHeader } from './SettingsFormControls' import { Badge } from '../ui/badge' import { translate } from '@/i18n/i18n' +import { getShortcutPlatform } from '@/lib/shortcut-platform' import { RELEASE_CHANNELS, getVersionChannel, + isChannelSupportedOnPlatform, parseHourlyVersionStamp, type ReleaseBuild, type ReleaseChannel @@ -25,7 +27,7 @@ const CHANNEL_LABELS: Record = { const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', - hourly: 'Unvetted macOS builds from main, built every hour. No tests, no notarization.' + hourly: 'macOS only. Unvetted builds from main, built every hour. No tests, no notarization.' } function formatBuildLabel(build: ReleaseBuild): string { @@ -54,8 +56,15 @@ export function ReleaseChannelSection(): React.JSX.Element { const [loading, setLoading] = useState(false) const [selectedTag, setSelectedTag] = useState(null) + const platform = getShortcutPlatform() const runningChannel = appVersion ? getVersionChannel(appVersion) : null - const activeChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + const requestedChannel = releaseChannelOverride ?? runningChannel ?? 'stable' + // Why: a persisted 'hourly' can arrive on Linux/Windows — settings sync, or a + // profile carried over from a Mac. Fall back rather than rendering a selected + // segment the user cannot act on and a build list that can never install. + const activeChannel = isChannelSupportedOnPlatform(requestedChannel, platform) + ? requestedChannel + : 'stable' const busy = updateStatus.state === 'checking' || updateStatus.state === 'downloading' useEffect(() => { @@ -170,10 +179,36 @@ export function ReleaseChannelSection(): React.JSX.Element { 'auto.components.settings.ReleaseChannelSection.channelAriaLabel', 'Update channel' )} - options={RELEASE_CHANNELS.map((channel) => ({ - value: channel, - label: CHANNEL_LABELS[channel] - }))} + // Why disabled rather than hidden: a Linux/Windows dev who has heard + // about the hourly channel should see that it exists and why it is + // unavailable, instead of silently not finding it. + options={RELEASE_CHANNELS.map((channel) => { + const supported = isChannelSupportedOnPlatform(channel, platform) + return { + value: channel, + label: supported ? ( + CHANNEL_LABELS[channel] + ) : ( + + {CHANNEL_LABELS[channel]} + + ), + disabled: !supported, + ariaLabel: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnlyAria', + 'Hourly (macOS only)' + ), + tooltip: supported + ? undefined + : translate( + 'auto.components.settings.ReleaseChannelSection.hourlyMacOnly', + 'Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC.' + ) + } + })} />

    {CHANNEL_DESCRIPTIONS[activeChannel]}

    diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e3c586a4d81..b38e6915083 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10089,7 +10089,9 @@ "switchTo": "Switch to build", "alreadyRunning": "This is the build you are running.", "willSwitch": "{{value0}} → {{value1}}", - "webUnavailable": "Switching builds is only available in the desktop app." + "webUnavailable": "Switching builds is only available in the desktop app.", + "hourlyMacOnlyAria": "Hourly (macOS only)", + "hourlyMacOnly": "Hourly builds are produced only for macOS. Linux and Windows stay on Stable or RC." } }, "right": { diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index c02ffe888c6..089487f0ad9 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -3,6 +3,7 @@ import { formatHourlyVersion, getReleaseRepoForChannel, getVersionChannel, + isChannelSupportedOnPlatform, isHourlyVersion, isReleaseChannel, parseHourlyVersionStamp, @@ -57,6 +58,22 @@ describe('release channel', () => { ) }) + // Why: the hourly workflow is macOS-only, so the channel has no artifact to + // offer elsewhere. Both the picker and the main-process check read this, so a + // regression here would silently re-expose an uninstallable channel. + it('offers hourly only on macOS', () => { + expect(isChannelSupportedOnPlatform('hourly', 'darwin')).toBe(true) + expect(isChannelSupportedOnPlatform('hourly', 'linux')).toBe(false) + expect(isChannelSupportedOnPlatform('hourly', 'win32')).toBe(false) + }) + + it('offers stable and rc on every platform', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(isChannelSupportedOnPlatform('stable', platform)).toBe(true) + expect(isChannelSupportedOnPlatform('rc', platform)).toBe(true) + } + }) + it('accepts only known channels', () => { expect(isReleaseChannel('hourly')).toBe(true) expect(isReleaseChannel('stable')).toBe(true) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 5295f444729..fc0c09cefb9 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -16,6 +16,18 @@ export function isReleaseChannel(value: unknown): value is ReleaseChannel { return typeof value === 'string' && RELEASE_CHANNELS.includes(value as ReleaseChannel) } +/** + * Hourly builds are produced only by the macOS workflow, so the channel has + * nothing to offer elsewhere. Shared so the picker, the main-process check, and + * any future surface cannot drift on where it is available. + */ +export function isChannelSupportedOnPlatform( + channel: ReleaseChannel, + platform: NodeJS.Platform +): boolean { + return channel !== 'hourly' || platform === 'darwin' +} + export function getReleaseRepoForChannel(channel: ReleaseChannel): string { return channel === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO } From ef9e6ab9a8ec37e6ecc4d64b568cd8b7e8df082d Mon Sep 17 00:00:00 2001 From: Eugenio Jesus Jose Valeiras Date: Fri, 31 Jul 2026 03:58:42 -0300 Subject: [PATCH 052/123] fix(board): stop truncating workflows longer than 12 columns (#11605) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/persistence.test.ts | 40 +++ .../sidebar/WorkspaceKanbanDrawer.tsx | 1 + .../sidebar/WorkspaceKanbanLaneGrid.test.tsx | 229 ++++++++++++++++++ .../sidebar/WorkspaceKanbanLaneGrid.tsx | 194 ++++++++++++--- .../WorkspaceKanbanSettingsMenu.test.tsx | 35 ++- .../sidebar/workspace-kanban-lane-range.ts | 17 ++ src/shared/workspace-statuses.test.ts | 23 ++ src/shared/workspace-statuses.ts | 3 +- ...orkspace-board-lane-virtualization.spec.ts | 155 ++++++++++++ 9 files changed, 657 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.test.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ba69a92c524..d8e01166973 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -6840,6 +6840,46 @@ describe('Store', () => { expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true) }) + it('preserves workflows above 20 statuses across load, write, and restart', async () => { + const imported = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })).toReversed() + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { workspaceStatuses: imported }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + imported.map((status) => status.id) + ) + + const authored = Array.from({ length: 64 }, (_, index) => ({ + id: `final-${String(index + 1).padStart(3, '0')}`, + label: `Final ${index + 1}` + })).toReversed() + store.updateUI({ workspaceStatuses: authored }) + store.flush() + + expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + expect( + (readDataFile() as PersistedState).ui.workspaceStatuses?.map((status) => status.id) + ).toEqual(authored.map((status) => status.id)) + + const restarted = await createStore() + expect(restarted.getUI().workspaceStatuses?.map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('repairs the known-bad reordered default workspace statuses once on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index e67fd44dd18..d3998031dcb 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -899,6 +899,7 @@ export default function WorkspaceKanbanDrawer({ className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek" > ({ startIndex: 0, visibleCount: 4 })) +const animationFrames = new Map() +let nextAnimationFrameId = 1 + +vi.mock('@tanstack/react-virtual', () => { + const defaultRangeExtractor = (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }): number[] => { + const start = Math.max(0, range.startIndex - range.overscan) + const end = Math.min(range.count - 1, range.endIndex + range.overscan) + return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index) + } + return { + defaultRangeExtractor, + useVirtualizer: (options: { + count: number + estimateSize: (index: number) => number + getItemKey: (index: number) => string | number + gap: number + rangeExtractor: (range: { + startIndex: number + endIndex: number + overscan: number + count: number + }) => number[] + }) => { + const endIndex = Math.min( + options.count - 1, + virtualWindow.startIndex + virtualWindow.visibleCount - 1 + ) + const indexes = + options.count === 0 + ? [] + : options.rangeExtractor({ + startIndex: virtualWindow.startIndex, + endIndex, + overscan: 1, + count: options.count + }) + const size = options.estimateSize(0) + return { + getTotalSize: () => Math.max(0, options.count * size + (options.count - 1) * options.gap), + getVirtualItems: () => + indexes.map((index) => ({ + index, + key: options.getItemKey(index), + start: index * (size + options.gap) + })), + measureElement: () => {}, + measure: () => {} + } + } + } +}) + +vi.mock('./WorkspaceKanbanStatusLane', () => ({ + default: ({ + status, + items, + renderCards + }: { + status: WorkspaceStatusDefinition + items: readonly Worktree[] + renderCards: boolean + }) => ( +
    + +
    + ) +})) + +const { default: WorkspaceKanbanLaneGrid } = await import('./WorkspaceKanbanLaneGrid') +const { extractWorkspaceKanbanLaneRange } = await import('./workspace-kanban-lane-range') + +const STATUSES = Array.from({ length: 21 }, (_, index) => ({ + id: `state-${String(index + 1).padStart(2, '0')}`, + label: `State ${index + 1}` +})) +const REPO_MAP = new Map() + +function makeGrid(): React.JSX.Element { + return ( + {}} + onDragLeave={() => {}} + onDrop={() => {}} + onActivate={() => {}} + onSelectionGesture={() => false} + onContextMenuSelect={() => []} + onCreateWorktree={() => {}} + onColumnResizeStart={() => {}} + onColumnResizeKeyDown={() => {}} + /> + ) +} + +function renderGrid(): ReturnType { + return render(makeGrid()) +} + +function mountedStatusIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-workspace-status]')).map( + (lane) => lane.dataset.workspaceStatus ?? '' + ) +} + +function flushNextAnimationFrame(): void { + const next = animationFrames.entries().next().value as [number, FrameRequestCallback] | undefined + expect(next).toBeDefined() + if (!next) { + return + } + animationFrames.delete(next[0]) + act(() => next[1](performance.now())) +} + +beforeEach(() => { + animationFrames.clear() + nextAnimationFrameId = 1 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = nextAnimationFrameId + nextAnimationFrameId += 1 + animationFrames.set(id, callback) + return id + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + animationFrames.delete(id) + }) +}) + +afterEach(() => { + virtualWindow.startIndex = 0 + cleanup() + vi.restoreAllMocks() +}) + +describe('WorkspaceKanbanLaneGrid', () => { + it('reserves the full workflow width while mounting only the horizontal window', () => { + const { container } = renderGrid() + + expect(mountedStatusIds(container)).toEqual([ + 'state-01', + 'state-02', + 'state-03', + 'state-04', + 'state-05' + ]) + expect( + container.querySelector('[data-workspace-board-lane-grid]')?.style.width + ).toBe(`${21 * 308 + 20 * 12}px`) + }) + + it('mounts later ordered lanes and releases distant lanes after horizontal scroll', () => { + const rendered = renderGrid() + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + expect(rendered.container.querySelector('[data-workspace-status="state-01"]')).toBeNull() + }) + + it('keeps one focused lane mounted without unbounding the virtual window', () => { + const rendered = renderGrid() + fireEvent.focus(rendered.getByRole('button', { name: 'State 1' })) + + virtualWindow.startIndex = 17 + rendered.rerender(makeGrid()) + + expect(mountedStatusIds(rendered.container)).toEqual([ + 'state-01', + 'state-17', + 'state-18', + 'state-19', + 'state-20', + 'state-21' + ]) + }) + + it('adds only the focused lane to the normal overscanned range', () => { + expect( + extractWorkspaceKanbanLaneRange({ startIndex: 4, endIndex: 7, overscan: 1, count: 21 }, 18) + ).toEqual([3, 4, 5, 6, 7, 8, 18]) + }) + + it('hydrates at most one mounted lane per animation frame', () => { + const { container } = renderGrid() + const renderedLaneCount = (): number => + container.querySelectorAll('[data-render-cards="true"]').length + + expect(renderedLaneCount()).toBe(0) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(1) + flushNextAnimationFrame() + expect(renderedLaneCount()).toBe(2) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 12506678b63..48d77c9b7f7 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -1,4 +1,13 @@ -import React from 'react' +import React, { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { useVirtualizer, type Range } from '@tanstack/react-virtual' import type { Repo, WorkspaceStatus, @@ -6,12 +15,17 @@ import type { Worktree } from '../../../../shared/types' import type { WorkspaceKanbanLaneView } from './workspace-kanban-search' +import { extractWorkspaceKanbanLaneRange } from './workspace-kanban-lane-range' import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane' // Why: a fresh [] per render would defeat the memoized lane on empty lanes. const EMPTY_LANE_ITEMS: readonly Worktree[] = [] +const EMPTY_RENDERED_LANE_IDS: ReadonlySet = new Set() +const WORKSPACE_BOARD_LANE_GAP = 12 +const WORKSPACE_BOARD_LANE_OVERSCAN = 1 type WorkspaceKanbanLaneGridProps = { + laneScrollerRef: React.RefObject statuses: readonly WorkspaceStatusDefinition[] laneViews: ReadonlyMap laneFullWorktreeIds: ReadonlyMap @@ -41,6 +55,7 @@ type WorkspaceKanbanLaneGridProps = { } export default function WorkspaceKanbanLaneGrid({ + laneScrollerRef, statuses, laneViews, laneFullWorktreeIds, @@ -65,44 +80,155 @@ export default function WorkspaceKanbanLaneGrid({ onColumnResizeStart, onColumnResizeKeyDown }: WorkspaceKanbanLaneGridProps): React.JSX.Element { + const [focusedStatusId, setFocusedStatusId] = useState(null) + const [renderedLaneIds, setRenderedLaneIds] = + useState>(EMPTY_RENDERED_LANE_IDS) + const renderedLaneIdsRef = useRef(renderedLaneIds) + const renderCardsRef = useRef(renderCards) + useLayoutEffect(() => { + renderedLaneIdsRef.current = renderedLaneIds + renderCardsRef.current = renderCards + }, [renderCards, renderedLaneIds]) + const focusedIndex = useMemo( + () => + focusedStatusId === null + ? null + : statuses.findIndex((status) => status.id === focusedStatusId), + [focusedStatusId, statuses] + ) + const estimateLaneSize = useCallback(() => columnWidth, [columnWidth]) + const getLaneKey = useCallback((index: number) => statuses[index]?.id ?? index, [statuses]) + const rangeExtractor = useCallback( + (range: Range) => extractWorkspaceKanbanLaneRange(range, focusedIndex), + [focusedIndex] + ) + const laneVirtualizer = useVirtualizer({ + count: statuses.length, + getScrollElement: () => laneScrollerRef.current, + estimateSize: estimateLaneSize, + getItemKey: getLaneKey, + horizontal: true, + overscan: WORKSPACE_BOARD_LANE_OVERSCAN, + gap: WORKSPACE_BOARD_LANE_GAP, + rangeExtractor, + useFlushSync: false + }) + useLayoutEffect(() => { + laneVirtualizer.measure() + }, [columnWidth, laneVirtualizer]) + const virtualLanes = laneVirtualizer.getVirtualItems() + const virtualStatusIds = useMemo( + () => + virtualLanes.flatMap((virtualLane) => { + const status = statuses[virtualLane.index] + return status ? [status.id] : [] + }), + [statuses, virtualLanes] + ) + const mountedLaneIds = useMemo(() => new Set(virtualStatusIds), [virtualStatusIds]) + const mountedLaneIdsRef = useRef>(mountedLaneIds) + useLayoutEffect(() => { + mountedLaneIdsRef.current = mountedLaneIds + }, [mountedLaneIds]) + useEffect(() => { + if (!renderCards) { + setRenderedLaneIds(EMPTY_RENDERED_LANE_IDS) + return + } + const missingIds = virtualStatusIds.filter((id) => !renderedLaneIdsRef.current.has(id)) + setRenderedLaneIds((current) => { + const retained = new Set(Array.from(current).filter((id) => mountedLaneIds.has(id))) + return retained.size === current.size ? current : retained + }) + let nextIndex = 0 + let frameId = 0 + const renderNextLane = (): void => { + const statusId = missingIds[nextIndex] + nextIndex += 1 + if (!statusId) { + return + } + startTransition(() => { + setRenderedLaneIds((current) => { + if (!renderCardsRef.current || !mountedLaneIdsRef.current.has(statusId)) { + return current + } + return new Set(current).add(statusId) + }) + }) + if (nextIndex < missingIds.length) { + frameId = window.requestAnimationFrame(renderNextLane) + } + } + if (missingIds.length > 0) { + frameId = window.requestAnimationFrame(renderNextLane) + } + return () => window.cancelAnimationFrame(frameId) + }, [mountedLaneIds, renderCards, virtualStatusIds]) + return (
    { + const lane = (event.target as Element).closest('[data-workspace-status]') + setFocusedStatusId(lane?.dataset.workspaceStatus ?? null) + }} + onBlurCapture={(event) => { + const nextTarget = event.relatedTarget + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setFocusedStatusId(null) + } }} > - {statuses.map((status) => ( - - ))} + {virtualLanes.map((virtualLane) => { + const status = statuses[virtualLane.index] + if (!status) { + return null + } + return ( +
    + +
    + ) + })}
    ) } diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx index 35e9f8a0c92..bba46379d77 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx @@ -25,14 +25,22 @@ import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' let root: Root | null = null let container: HTMLDivElement | null = null -function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void { +function renderMenu({ + workspaceStatuses = statuses, + onSyncTaskStatusFromWorkspaceBoardChange = vi.fn<(enabled: boolean) => void>(), + onAddStatus = vi.fn<() => void>() +}: { + workspaceStatuses?: WorkspaceStatusDefinition[] + onSyncTaskStatusFromWorkspaceBoardChange?: (enabled: boolean) => void + onAddStatus?: () => void +} = {}): void { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root?.render( ) }) @@ -59,7 +67,7 @@ afterEach(() => { describe('WorkspaceKanbanSettingsMenu', () => { it('renders the task status sync switch and forwards changes', async () => { const onChange = vi.fn() - renderMenu(onChange) + renderMenu({ onSyncTaskStatusFromWorkspaceBoardChange: onChange }) const toggle = document.querySelector( 'button[role="switch"][aria-label="Sync board and issue status"]' @@ -74,4 +82,23 @@ describe('WorkspaceKanbanSettingsMenu', () => { expect(onChange).toHaveBeenCalledWith(true) }) + + it('keeps adding available for workflows above the former board limit', () => { + const onAddStatus = vi.fn() + renderMenu({ + workspaceStatuses: Array.from({ length: 21 }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })), + onAddStatus + }) + + const addStatus = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Add status' + ) + + expect(addStatus?.disabled).toBe(false) + addStatus?.click() + expect(onAddStatus).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts new file mode 100644 index 00000000000..84acef4a4bc --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-lane-range.ts @@ -0,0 +1,17 @@ +import { defaultRangeExtractor, type Range } from '@tanstack/react-virtual' + +export function extractWorkspaceKanbanLaneRange( + range: Range, + focusedIndex: number | null +): number[] { + const indexes = defaultRangeExtractor(range) + if ( + focusedIndex === null || + focusedIndex < 0 || + focusedIndex >= range.count || + indexes.includes(focusedIndex) + ) { + return indexes + } + return [...indexes, focusedIndex].sort((left, right) => left - right) +} diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index 9d3e881345a..bd12a4100e6 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -10,6 +10,29 @@ import { } from './workspace-statuses' describe('workspace status visuals', () => { + it.each([13, 20, 21, 64])('keeps all %i authored columns in order', (count) => { + const authored = Array.from({ length: count }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })) + + const statuses = normalizeWorkspaceStatuses(authored) + + expect(statuses).toHaveLength(count) + expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id)) + }) + + it('normalizes every valid status without truncating the workflow', () => { + const authored = Array.from({ length: 500 }, (_, index) => ({ + id: `state-${index}`, + label: `State ${index}` + })) + + expect(normalizeWorkspaceStatuses(authored).map((status) => status.id)).toEqual( + authored.map((status) => status.id) + ) + }) + it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ 'todo', diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index b3aa587043b..430595733fc 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -9,7 +9,6 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' const MAX_STATUS_LABEL_LENGTH = 32 -const MAX_WORKSPACE_STATUSES = 12 type WorkspaceStatusNormalizationOptions = { migrateDefaultWorkflowStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean @@ -169,7 +168,7 @@ function normalizeWorkspaceStatusesInternal( const statuses: WorkspaceStatusDefinition[] = [] const usedIds = new Set() - for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) { + for (const rawStatus of value) { if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) { continue } diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 850a83024a7..58db14bcdd1 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -3,6 +3,8 @@ import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' const SEEDED_WORKSPACE_COUNT = 300 const MARQUEE_WORKSPACE_COUNT = 102 +const MANY_LANE_COUNT = 21 +const CARDS_PER_LANE = 100 /** * Why: the board used to mount every workspace card in every lane in one @@ -151,6 +153,159 @@ test.describe('Workspace board lane virtualization', () => { await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before) }) + test('bounds mounted lanes and cards while preserving a 21-status workflow', async ({ + orcaPage + }) => { + const statusIds = Array.from( + { length: MANY_LANE_COUNT }, + (_, index) => `state-${String(index + 1).padStart(2, '0')}` + ) + await orcaPage.evaluate( + ({ cardsPerLane, ids }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const repo = state.repos[0] + if (!repo) { + throw new Error('Expected a seeded e2e repo') + } + const now = Date.now() + const synthetic = ids.flatMap((status, statusIndex) => + Array.from({ length: cardsPerLane }, (_, cardIndex) => { + const suffix = `${String(statusIndex + 1).padStart(2, '0')}-${String( + cardIndex + 1 + ).padStart(3, '0')}` + return { + id: `${repo.id}::/virtual-lane-${suffix}`, + instanceId: `virtual-lane-${suffix}`, + repoId: repo.id, + path: `${repo.path}/../virtual-lane-${suffix}`, + displayName: `Virtual lane ${suffix}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + manualOrder: 20_000 - statusIndex * cardsPerLane - cardIndex, + lastActivityAt: now - statusIndex * cardsPerLane - cardIndex, + head: '0000000000000000000000000000000000000000', + branch: `virtual-lane-${suffix}`, + isBare: false, + isMainWorktree: false, + workspaceStatus: status + } + }) + ) + + state.setSidebarOpen(true) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + state.setWorkspaceBoardColumnWidth(308) + state.setWorkspaceStatuses( + ids.map((id, index) => ({ + id, + label: `State ${index + 1}` + })) + ) + store.setState({ + sortBy: 'manual', + worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: synthetic } + }) + }, + { cardsPerLane: CARDS_PER_LANE, ids: statusIds } + ) + + await orcaPage.getByRole('button', { name: 'Workspace board' }).click() + + const board = orcaPage.locator('[data-workspace-board-selection-surface]') + const scroller = board.locator('[data-workspace-board-lane-grid]').locator('..') + const lanes = board.locator('[data-workspace-status]') + const cards = board.locator('[data-workspace-board-card-id]') + await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3) + + const laneBudget = await scroller.evaluate( + (element) => Math.ceil(element.clientWidth / 320) + 3 + ) + const initialLaneCount = await lanes.count() + expect(initialLaneCount).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan(initialLaneCount * 40) + expect(await board.locator('*').count()).toBeLessThan(initialLaneCount * 550 + 200) + await expect(board.locator('[data-workspace-status="state-01"]')).toBeVisible() + expect(await board.locator('[data-workspace-status="state-21"]').count()).toBe(0) + + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + + await expect(board.locator('[data-workspace-status="state-21"]')).toBeVisible() + await expect.poll(() => board.locator('[data-workspace-status="state-01"]').count()).toBe(0) + const finalIds = await lanes.evaluateAll((elements) => + elements.map((element) => (element as HTMLElement).dataset.workspaceStatus ?? '') + ) + expect(finalIds).toEqual([...finalIds].sort()) + expect(finalIds).toContain('state-21') + expect(await lanes.count()).toBeLessThanOrEqual(laneBudget) + expect(await cards.count()).toBeLessThan((await lanes.count()) * 40) + expect( + await orcaPage.evaluate(() => + window.__store?.getState().workspaceStatuses.map((status) => status.id) + ) + ).toEqual(statusIds) + + const finalLane = board.locator('[data-workspace-status="state-21"]') + const resizeHandle = finalLane.getByRole('separator', { + name: 'Resize workspace board columns' + }) + await resizeHandle.focus() + await resizeHandle.press('ArrowRight') + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().workspaceBoardColumnWidth)) + .toBe(328) + await expect(resizeHandle).toHaveAttribute('aria-valuenow', '328') + await scroller.evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + await expect(finalLane).toBeVisible() + + const sourceCard = board + .locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]') + .first() + const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id') + const sourceBox = await sourceCard.boundingBox() + const targetBox = await finalLane + .locator('[data-workspace-board-lane-scroll]') + .first() + .boundingBox() + if (!sourceId || !sourceBox || !targetBox) { + throw new Error('Expected visible source card and final lane drop target') + } + await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await orcaPage.mouse.down() + await orcaPage.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + Math.min(80, targetBox.height / 2), + { steps: 8 } + ) + await orcaPage.mouse.up() + await expect + .poll(() => + orcaPage.evaluate( + (worktreeId) => + window.__store?.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus, + sourceId + ) + ) + .toBe('state-21') + }) + test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => { const statusId = 'virtual-marquee' await orcaPage.evaluate( From e467b3ff7b1526a06a176cd4e198febcb989344f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:22:53 -0700 Subject: [PATCH 053/123] fix(remote): stabilize shared control and terminal parking (#11656) * fix(remote): stabilize shared control and terminal parking * fix(remote): harden parking review edge cases * fix(terminal): restore parked local floating buffer * fix(ci): drop superseded paired parking evidence * fix(terminal): preserve floating park watchers * fix(ci): include web client in paired e2e artifact * fix(ci): reuse renderer build for paired e2e --- .github/workflows/e2e.yml | 11 +- config/reliability-gates.jsonc | 20 +- .../release-e2e-dispatch-contract.test.mjs | 10 + .../remote-shared-control-retirement-probe.ts | 258 ++++++++++++++++ src/main/ipc/pty.test.ts | 11 + src/main/ipc/pty.ts | 7 +- .../providers/pty-process-inspection.test.ts | 26 +- src/main/providers/pty-process-inspection.ts | 14 + src/renderer/src/components/Terminal.tsx | 21 +- .../FloatingTerminalPanel.test.tsx | 73 ++++- .../FloatingTerminalPanel.tsx | 88 ++++-- .../terminal-pane/pty-connection.test.ts | 60 +++- .../terminal-pane/pty-connection.ts | 6 +- .../terminal-parked-tab-watchers.ts | 3 +- .../terminal-parked-watcher-registry.test.ts | 41 ++- .../terminal-parked-watcher-registry.ts | 6 + .../use-terminal-tab-cold-parking.test.ts | 29 ++ .../use-terminal-tab-cold-parking.ts | 12 +- ...background-terminal-worktree-mount.test.ts | 14 +- .../background-terminal-worktree-mount.ts | 15 +- ...emote-runtime-shared-control-connection.ts | 24 +- ...e-runtime-shared-control-frame-dispatch.ts | 19 +- ...te-runtime-shared-control-frame-handler.ts | 3 + .../remote-runtime-shared-control-requests.ts | 2 + ...shared-control-retired-request-ids.test.ts | 62 ++++ ...time-shared-control-retired-request-ids.ts | 53 ++++ ...-shared-control-subscription-close.test.ts | 59 ++++ ...ntime-shared-control-subscription-close.ts | 44 +++ ...te-runtime-shared-control-subscriptions.ts | 8 +- ...d-remote-terminal-retention-memory.spec.ts | 35 ++- .../helpers/headless-paired-runtime-host.ts | 96 +++++- .../headless-paired-runtime-host.unit.test.ts | 45 +++ ...ed-terminal-cold-activation-observation.ts | 55 ++++ .../paired-terminal-cold-activation-oracle.ts | 292 ++++++++++++++++++ 34 files changed, 1425 insertions(+), 97 deletions(-) create mode 100644 config/scripts/remote-shared-control-retirement-probe.ts create mode 100644 src/shared/remote-runtime-shared-control-retired-request-ids.test.ts create mode 100644 src/shared/remote-runtime-shared-control-retired-request-ids.ts create mode 100644 src/shared/remote-runtime-shared-control-subscription-close.test.ts create mode 100644 src/shared/remote-runtime-shared-control-subscription-close.ts create mode 100644 tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts create mode 100644 tests/e2e/helpers/paired-terminal-cold-activation-observation.ts create mode 100644 tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2ab1ed44421..ac5fd76bc87 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -64,11 +64,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Why: building once avoids five parallel electron-vite builds inside - # Playwright globalSetup, which otherwise contends for CPU/RAM on OSS - # runners before the sharded tests even start. + # Why: building here avoids parallel builds inside Playwright globalSetup; + # paired-browser specs also need the standalone web bundle. - name: Build Electron app for E2E - run: npx electron-vite build --mode e2e + env: + VITE_EXPOSE_STORE: 'true' + run: | + npx electron-vite build --mode e2e + pnpm run build:web-from-renderer - name: Upload E2E build output uses: actions/upload-artifact@v7 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 312f89c7882..a243d500d0b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3105,7 +3105,7 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ @@ -3170,6 +3170,7 @@ "file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", "assertions": [ "ordinary-parks paired terminals against an isolated headless Orca host", + "cold-activates only visible paired terminals against an isolated headless host", "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" ] }, @@ -3314,13 +3315,13 @@ "summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material." }, { - "date": "2026-07-29", + "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "passed", - "durationSeconds": 14.2, - "summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle." + "durationSeconds": 23, + "summary": "The isolated headless `orca serve` host passed ordinary parking plus cold activation: deferred tabs stayed unmounted until activation, preserved PTY identity and parked output, and resumed live input." }, { "date": "2026-07-29", @@ -3348,15 +3349,6 @@ "result": "passed", "durationSeconds": 17.5, "summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 14.0, - "summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle." } ], "runtimeBudget": { diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index b3a88d08bce..7256a76bf62 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => { expect(refInput.type).toBe('string') expect(refInput.required).toBe(false) }) + + it('includes the paired-runtime web client in the shared E2E build artifact', () => { + const buildStep = e2eWorkflow.jobs.build.steps.find( + (step) => step.name === 'Build Electron app for E2E' + ) + + expect(buildStep.run).toContain('electron-vite build --mode e2e') + expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true') + expect(buildStep.run).toContain('pnpm run build:web-from-renderer') + }) }) diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts new file mode 100644 index 00000000000..63abd01e376 --- /dev/null +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -0,0 +1,258 @@ +import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata' +import type { PairingOffer } from '../../src/shared/pairing' +import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection' +import { + resolveEnvironment, + resolveEnvironmentPairingOffer +} from '../../src/shared/runtime-environment-store' +import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types' + +async function main(): Promise { + const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME + if (!environmentName) { + throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required') + } + const userDataPath = getDefaultUserDataPath() + const environment = resolveEnvironment(userDataPath, environmentName) + const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id) + const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100) + const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200) + const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000) + const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000) + const unknownResponses = new Map() + const originalWarn = console.warn + console.warn = (message?: unknown, details?: unknown): void => { + if ( + message === '[remote-runtime.shared-control] unknown response id' && + typeof details === 'object' && + details !== null + ) { + const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown') + unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1) + return + } + originalWarn(message, details) + } + try { + const startedAt = Date.now() + const before = await requestMemorySnapshot(pairing, environment.id) + let ok = 0 + let subscriptionResponses = 0 + let runtimeStatus: RuntimeStatus | null = null + const cleanupDurationsMs: number[] = [] + for (let cycle = 0; cycle < cycles; cycle += 1) { + const result = await runCycle({ + pairing, + environmentId: environment.id, + concurrency, + settleMs, + cleanupTimeoutMs + }) + ok += result.ok + subscriptionResponses += result.subscriptionResponses + runtimeStatus ??= result.runtimeStatus + cleanupDurationsMs.push(result.cleanupDurationMs) + } + await wait(settleMs) + const after = await requestMemorySnapshot(pairing, environment.id) + console.log( + JSON.stringify({ + environment: { id: environment.id, name: environment.name }, + runtime: runtimeStatus + ? { + appVersion: runtimeStatus.appVersion ?? null, + capabilities: runtimeStatus.capabilities ?? [], + hostPlatform: runtimeStatus.hostPlatform ?? null + } + : null, + cycles, + concurrency, + requests: cycles * concurrency, + ok, + subscriptionResponses, + cleanupDurationMs: { + average: Math.round( + cleanupDurationsMs.reduce((total, duration) => total + duration, 0) / + cleanupDurationsMs.length + ), + maximum: Math.max(...cleanupDurationsMs) + }, + unknownResponseFrames: Array.from(unknownResponses.values()).reduce( + (total, count) => total + count, + 0 + ), + unknownResponseIds: unknownResponses.size, + memory: { + before: summarizeMemory(before), + after: summarizeMemory(after), + appDelta: after.app.memory - before.app.memory + }, + elapsedMs: Date.now() - startedAt + }) + ) + } finally { + console.warn = originalWarn + } +} + +async function runCycle(args: { + pairing: PairingOffer + environmentId: string + concurrency: number + settleMs: number + cleanupTimeoutMs: number +}): Promise<{ + ok: number + subscriptionResponses: number + cleanupDurationMs: number + runtimeStatus: RuntimeStatus | null +}> { + const connection = new RemoteRuntimeSharedControlConnection(args.pairing, { + environmentId: args.environmentId + }) + try { + const responses = await Promise.all( + Array.from({ length: args.concurrency }, () => + connection.request('status.get', undefined, 10_000) + ) + ) + const runtimeStatus = responses.find((response) => response.ok) + let subscriptionResponses = 0 + const subscriptions = await Promise.all([ + connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, { + onResponse: () => { + subscriptionResponses += 1 + }, + onError: () => {} + }), + connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, { + onResponse: () => { + subscriptionResponses += 1 + }, + onError: () => {} + }) + ]) + await wait(args.settleMs) + for (const subscription of subscriptions) { + subscription.close() + } + const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs) + // Let cleanup replies reach the retirement cache before closing the socket. + await wait(args.settleMs) + return { + ok: responses.filter((response) => response.ok).length, + subscriptionResponses, + cleanupDurationMs, + runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null + } + } finally { + connection.close() + } +} + +async function waitForConnectionIdle( + connection: RemoteRuntimeSharedControlConnection, + timeoutMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const diagnostics = connection.getDiagnostics() + if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) { + return Date.now() - startedAt + } + await wait(25) + } + throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`) +} + +async function requestMemorySnapshot( + pairing: PairingOffer, + environmentId: string +): Promise { + const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId }) + try { + const response = await connection.request( + 'diagnostics.memory', + undefined, + 20_000 + ) + if (!response.ok) { + throw new Error(`Memory snapshot failed: ${response.error.message}`) + } + return response.result + } finally { + connection.close() + } +} + +function summarizeMemory(snapshot: MemorySnapshot): { + app: MemorySnapshot['app'] + host: MemorySnapshot['host'] + processMemoryMetric: MemorySnapshot['processMemoryMetric'] + totalCpu: number + totalMemory: number + worktreeCount: number + sessionCount: number + worktreeMemory: number + topWorktrees: { + worktreeName: string + repoName: string + cpu: number + memory: number + sessionCount: number + topSessions: { pid: number; cpu: number; memory: number }[] + }[] +} { + return { + app: snapshot.app, + host: snapshot.host, + processMemoryMetric: snapshot.processMemoryMetric, + totalCpu: snapshot.totalCpu, + totalMemory: snapshot.totalMemory, + worktreeCount: snapshot.worktrees.length, + sessionCount: snapshot.worktrees.reduce( + (total, worktree) => total + worktree.sessions.length, + 0 + ), + worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0), + topWorktrees: [...snapshot.worktrees] + .sort((left, right) => right.memory - left.memory) + .slice(0, 10) + .map((worktree) => ({ + worktreeName: worktree.worktreeName, + repoName: worktree.repoName, + cpu: worktree.cpu, + memory: worktree.memory, + sessionCount: worktree.sessions.length, + topSessions: [...worktree.sessions] + .sort((left, right) => right.memory - left.memory) + .slice(0, 5) + .map((session) => ({ + pid: session.pid, + cpu: session.cpu, + memory: session.memory + })) + })) + } +} + +function readProbeInteger(name: string, fallback: number, maximum: number): number { + const value = process.env[name] + if (value === undefined) { + return fallback + } + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`${name} must be an integer from 1 through ${maximum}`) + } + return parsed +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +void main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c0798fe39fa..06d736088ed 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6736,6 +6736,17 @@ describe('registerPtyHandlers', () => { }) }) + it('settles a stale renderer process inspection as unavailable', async () => { + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ hasPty: vi.fn(() => false) } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id: 'gone-pty' })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + }) + // Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync). describe('pty:getSize reports applied size, not requested size', () => { function setupProviderWithAppliedSize(args: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 260f0d4d77d..1567eca121d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -68,7 +68,10 @@ import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' +import { + inspectPtyProviderProcess, + inspectPtyProviderProcessForRenderer +} from '../providers/pty-process-inspection' import { PtyProcessListAdmission, visitPtyProcessListingsInBatches @@ -6178,7 +6181,7 @@ export function registerPtyHandlers( ) ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcess(getProviderForPty(args.id), args.id) + inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) ) ipcMain.handle( diff --git a/src/main/providers/pty-process-inspection.test.ts b/src/main/providers/pty-process-inspection.test.ts index b18214b3352..ab697311248 100644 --- a/src/main/providers/pty-process-inspection.test.ts +++ b/src/main/providers/pty-process-inspection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { IPtyProvider } from './types' -import { inspectPtyProviderProcess } from './pty-process-inspection' +import { + inspectPtyProviderProcess, + inspectPtyProviderProcessForRenderer +} from './pty-process-inspection' describe('PTY provider process inspection', () => { it('rejects a missing provider PTY instead of returning idle evidence', async () => { @@ -25,6 +28,27 @@ describe('PTY provider process inspection', () => { expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') }) + it('returns unavailable to the renderer when a stale PTY is gone', async () => { + const provider = { + hasPty: vi.fn(() => false) + } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-missing')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + }) + + it('preserves non-stale renderer inspection failures', async () => { + const failure = new Error('daemon unavailable') + const provider = { + inspectProcess: vi.fn().mockRejectedValue(failure) + } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-1')).rejects.toBe(failure) + }) + it('preserves an unavailable inspection result', async () => { const inspection = { foregroundProcess: null, diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 760095c2e1f..852960c6b7d 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -25,3 +25,17 @@ export async function inspectPtyProviderProcess( const hasChildProcesses = await provider.hasChildProcesses(ptyId) return { foregroundProcess, hasChildProcesses } } + +export async function inspectPtyProviderProcessForRenderer( + provider: IPtyProvider, + ptyId: string +): Promise { + try { + return await inspectPtyProviderProcess(provider, ptyId) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_gone') { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true } + } + throw error + } +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 5f9c333eaa3..561dd4e8c40 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -89,6 +89,7 @@ import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/termi import { TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, canParkTerminalWorktreeRenderers, + isParkRestorableTerminalPty, selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalWorktrees, type TerminalWorktreeColdParkCandidate @@ -112,7 +113,8 @@ import { disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers + syncParkedTerminalTabWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-pane/terminal-parked-tab-watchers' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' @@ -1204,8 +1206,15 @@ function Terminal(): React.JSX.Element | null { } } const activationHostSupportsDeferral = canDeferColdActivationTabsForHost({ - executionHostId: activeWorktreeDeferralHostId + executionHostId: activeWorktreeDeferralHostId, + pairedRuntimeParkingEnvironmentIds }) + const isColdActivationPtyEligible = (ptyId: string): boolean => + isRemoteRuntimePtyId(ptyId) + ? isParkRestorableTerminalPty(ptyId, renderedActiveWorktreeId, { + pairedRuntimeParkingEnvironmentIds + }) + : terminalProviderHasAuthoritativeSnapshot(ptyId) if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) @@ -1226,7 +1235,7 @@ function Terminal(): React.JSX.Element | null { canWatcherCoverParkedTerminalTab( renderedActiveWorktreeId, tab, - terminalProviderHasAuthoritativeSnapshot + isColdActivationPtyEligible ) ) }, @@ -1243,7 +1252,7 @@ function Terminal(): React.JSX.Element | null { !canWatcherCoverParkedTerminalTab( renderedActiveWorktreeId, tab, - terminalProviderHasAuthoritativeSnapshot + isColdActivationPtyEligible ) ) { immediateTabIds.add(tab.id) @@ -1286,7 +1295,9 @@ function Terminal(): React.JSX.Element | null { ) // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. useEffect(() => { - pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + pruneParkedTerminalWatchers( + terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) + ) for (const workspace of workspaceSurfaces) { if ( anyMountedWorktreeHasLayout && diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 29917c17f51..d9f846fbbda 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -137,6 +137,7 @@ const mocks = vi.hoisted(() => ({ setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), + shouldDeferParkedPtyExitTabClose: vi.fn(), useContextualTour: vi.fn() })) @@ -144,6 +145,10 @@ const saveDialogBox = vi.hoisted(() => ({ fileId: null as string | null })) +const parkingBox = vi.hoisted(() => ({ + parkedTabIds: new Set() +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -205,6 +210,14 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({ } })) +vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', () => ({ + useTerminalTabColdParking: () => parkingBox.parkedTabIds +})) + +vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', () => ({ + shouldDeferParkedPtyExitTabClose: mocks.shouldDeferParkedPtyExitTabClose +})) + vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing })) @@ -574,6 +587,22 @@ function findByTypeName(node: unknown, typeName: string): ReactElementLike { return found } +function findAllByTypeName(node: unknown, typeName: string): ReactElementLike[] { + const found: ReactElementLike[] = [] + visit(node, (entry) => { + const candidate = + typeof entry.type === 'function' || typeof entry.type === 'object' + ? ((entry.type as { displayName?: string; name?: string }).displayName ?? + (entry.type as { displayName?: string; name?: string }).name ?? + '') + : entry.type + if (candidate === typeName) { + found.push(entry) + } + }) + return found +} + function findByProp(node: unknown, propName: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -780,6 +809,7 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.index = 0 hookRuntime.values = [] saveDialogBox.fileId = null + parkingBox.parkedTabIds = new Set() resetStore() // Why: the open-maximized intent is a module singleton; drain any leftover // from a prior test so it cannot bleed into an unrelated render. @@ -800,6 +830,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false) mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) + mocks.shouldDeferParkedPtyExitTabClose.mockReturnValue(false) const localStorage = { clear: vi.fn(), getItem: vi.fn(() => null), @@ -1477,6 +1508,20 @@ describe('FloatingTerminalPanel close behavior', () => { expect(openPane.props.isVisible).toBe(true) }) + it('does not mount floating terminal panes selected for cold parking', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + parkingBox.parkedTabIds = new Set(['tab-2']) + + await renderPanel(true) + runEffects() + await Promise.resolve() + const element = await renderPanel(true) + + expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([ + 'tab-1' + ]) + }) + it('routes titlebar Cmd+T to the floating workspace', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) @@ -2483,11 +2528,16 @@ describe('FloatingTerminalPanel close behavior', () => { const element = await renderPanel(true, onOpenChange) const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onPtyExit as () => void)() - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' }) + ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1') + expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', { + lifecyclePtyId: 'pty-1', + reason: 'pty-exit' + }) + expect(mocks.closeTab).not.toHaveBeenCalled() expect(onOpenChange).not.toHaveBeenCalled() - mocks.closeTab.mockClear() + mocks.closeTerminalTab.mockClear() ;(terminalPane.props.onCloseTab as () => void)() // Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune. expect(mocks.closeTerminalTab).toHaveBeenCalledWith( @@ -2498,6 +2548,23 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) + it('preserves split siblings when a parked PTY exits during reveal', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true) + + await renderPanel(true) + runEffects() + await Promise.resolve() + const element = await renderPanel(true) + const terminalPane = findByTypeName(element, 'TerminalPane') + + ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty') + + expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty') + expect(mocks.closeTerminalTab).not.toHaveBeenCalled() + expect(mocks.closeTab).not.toHaveBeenCalled() + }) + it('renders and closes simulator tabs in the floating workspace', async () => { const tab = setFloatingSimulatorTab() diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 003ef188f90..66c83ac063b 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -12,6 +12,8 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane, { type TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane' +import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { Button } from '@/components/ui/button' @@ -109,6 +111,7 @@ import { translate } from '@/i18n/i18n' import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inputs' const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const +const NO_ACTIVITY_TERMINAL_PORTALS = [] const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) @@ -349,6 +352,27 @@ export function FloatingTerminalPanel({ ? activeTab.entityId : null const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) + const terminalAssignments = useMemo(() => { + const assignments = new Map() + for (const tab of unifiedTabs) { + if (tab.contentType === 'terminal') { + assignments.set(tab.entityId, { + groupId: tab.groupId, + isActiveInGroup: tab.entityId === activeTerminalId + }) + } + } + return assignments + }, [activeTerminalId, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + terminalTabs: tabs, + assignments: terminalAssignments, + isWorktreeActive: open, + coldParkTerminalPanes: false, + shouldMeasureHiddenWorktree: false, + activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS + }) const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>( () => groupTabs @@ -1817,33 +1841,43 @@ export function FloatingTerminalPanel({ } > {cwd - ? tabs.map((tab) => { - const isActive = tab.id === activeTerminalId - return ( -
    - closeTab(tab.id, { reason: 'pty-exit' })} - onCloseTab={() => closeFloatingItemConfirmed(tab.id)} - /> -
    - ) - }) + ? tabs + .filter((tab) => !parkedTerminalTabIds.has(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTerminalId + return ( +
    + { + if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) { + return + } + closeTerminalTab(tab.id, { + reason: 'pty-exit', + lifecyclePtyId: ptyId + }) + }} + onCloseTab={() => closeFloatingItemConfirmed(tab.id)} + /> +
    + ) + }) : null} {browserTabs.map((tab) => { const isActive = tab.id === activeBrowserTab?.id diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e80c19f1a1f..261916779d2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -55,10 +55,10 @@ async function drainFakeTimerWork(limit = 20): Promise { // Why: a reveal remount reads the still-live park watcher entry at connect time to // tell itself apart from an in-place reattach; the host disposes it a beat later. -async function parkTabForReveal(tabId: string, ptyId: string): Promise { +async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise { const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry') parkedWatchersByTabId.set(tabId, { - worktreeId: 'wt-1', + worktreeId, tabPtyId: ptyId, paneIdByPtyId: new Map([[ptyId, 1]]), disposersByPtyId: new Map([[ptyId, () => {}]]) @@ -20243,6 +20243,62 @@ describe('connectPanePty', () => { expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) }) + it('restores a local main-model snapshot after a contentless park reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const localPtyId = 'global-floating-terminal@@terminal-1' + const transport = createMockTransport(localPtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + transport.getPtyId.mockReturnValue(localPtyId) + return sessionId ? { id: localPtyId, isReattach: true } : null + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue({ + data: 'FLOATING-PARK-RESTORE-OK\r\n', + cols: 113, + rows: 32, + seq: 558, + source: 'headless' + }) + await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }] + }, + ptyIdsByTabId: { 'tab-1': [localPtyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: localPtyId } + } + } + } + + const pane = createPane(1) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + worktreeId: 'global-floating-terminal', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 }) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: localPtyId }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) + expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK') + }) + it('falls back to relay replay when the SSH model snapshot stalls', async () => { const { connectPanePty } = await import('./pty-connection') const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f4a4da3b9ac..dfa0a237ed3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7822,7 +7822,9 @@ export function connectPanePty( // the subscribe screen without keeping the old xterm mounted. let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { - if (isRemoteRuntimePtyId(ptyId)) { + if (parseAppSshPtyId(ptyId)) { + prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() + } else { try { prefetchedParkModelSnapshot = await serializeHiddenOutputSnapshot(ptyId, { scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) @@ -7830,8 +7832,6 @@ export function connectPanePty( } catch { prefetchedParkModelSnapshot = null } - } else { - prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() } if (!isCurrentReattachPayload()) { return false diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 04456bd9f5b..59d1b90db3e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -35,7 +35,8 @@ export { disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' export { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index 8af5ca19207..eb436c8e127 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -8,16 +8,24 @@ import { hasPreHandlerPtyExit } from './pty-pre-handler-buffer' import { + capturedPanesByTabId, parkedWatchersByTabId, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const TAB_ID = 'removed-parked-tab' const PTY_ID = 'removed-worktree@@parked-pty' +const FLOATING_TAB_ID = 'floating-parked-tab' +const FLOATING_PTY_ID = `${FLOATING_TERMINAL_WORKTREE_ID}@@parked-pty` describe('terminal parked watcher registry removal', () => { afterEach(() => { parkedWatchersByTabId.delete(TAB_ID) + parkedWatchersByTabId.delete(FLOATING_TAB_ID) + capturedPanesByTabId.delete(TAB_ID) + capturedPanesByTabId.delete(FLOATING_TAB_ID) clearPreHandlerPtyState(PTY_ID) }) @@ -51,4 +59,35 @@ describe('terminal parked watcher registry removal', () => { expect(exit).not.toHaveBeenCalled() expect(delayedData).not.toHaveBeenCalled() }) + + it('keeps the floating workspace while pruning a removed workspace', () => { + const removedDispose = vi.fn() + const floatingDispose = vi.fn() + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + tabPtyId: PTY_ID, + paneIdByPtyId: new Map([[PTY_ID, 1]]), + disposersByPtyId: new Map([[PTY_ID, removedDispose]]) + }) + parkedWatchersByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabPtyId: FLOATING_PTY_ID, + paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]), + disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]]) + }) + capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] }) + capturedPanesByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + panes: [] + }) + + pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds([])) + + expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false) + expect(capturedPanesByTabId.has(TAB_ID)).toBe(false) + expect(removedDispose).toHaveBeenCalledOnce() + expect(parkedWatchersByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(floatingDispose).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index fd34398824a..1f4b522040a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -8,6 +8,7 @@ * import cycle-free, mirroring how pty-dispatcher exports its handler maps. */ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type ParkedTerminalPaneCapture = { ptyId: string | null @@ -51,6 +52,11 @@ export function getParkedTerminalWatcherTabIds(): string[] { return Array.from(parkedWatchersByTabId.keys()) } +// Why: the floating workspace is synthetic, so repo/folder surface lists never include it. +export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): Set { + return new Set([...workspaceIds, FLOATING_TERMINAL_WORKTREE_ID]) +} + /** * Whether this tab is parked right now — the reveal remount's own mount effect * runs before the host effect that disposes the watcher (child effects first), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index e04f6c0080d..7138edb37fc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -6,6 +6,7 @@ import type { TerminalTab } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ storeState: { pendingStartupByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), settings: {} as Record, terminalLayoutsByTabId: {} as Record }> }, @@ -75,6 +76,34 @@ describe('useTerminalTabColdParking measure-clock contract', () => { mocks.exemptTabIds = new Set() mocks.exemptSelectCalls = 0 mocks.storeState.terminalLayoutsByTabId = {} + mocks.storeState.runtimeStatusByEnvironmentId = new Map() + }) + + it('parks paired-runtime tabs only when their exact host advertises restore', () => { + const environmentId = 'paired-env' + const remoteArgs = { + ...hookArgs(false), + terminalTabs: [ + { ...terminalTab('tab-1'), ptyId: `remote:${environmentId}@@term-1` }, + { ...terminalTab('tab-2'), ptyId: `remote:${environmentId}@@term-2` } + ] + } + for (const [advertisedEnvironmentId, expected] of [ + [environmentId, new Set(['tab-2'])], + ['other-env', new Set()] + ] as const) { + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [advertisedEnvironmentId, { status: { capabilities: ['terminal.paired-parking.v1'] } }] + ]) + const { result, unmount } = renderHook(() => useTerminalTabColdParking(remoteArgs)) + + act(() => { + vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) + }) + + expect(result.current).toEqual(expected) + unmount() + } }) // Why: the worktree layer preserves hiddenSince through a background-measure diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 85ea3e9e909..c339f00fe0a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -16,6 +16,7 @@ import { import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' import { TERMINAL_TAB_COLD_PARK_DELAY_MS, + selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalTabs, type TerminalTabColdParkCandidate } from './terminal-hidden-view-parking' @@ -90,6 +91,11 @@ export function useTerminalTabColdParking(args: { const terminalSshParkingEnabled = useAppStore( (state) => state.settings?.terminalSshViewParking !== false ) + const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const pairedRuntimeParkingEnvironmentIds = useMemo( + () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), + [runtimeStatusByEnvironmentId] + ) const terminalTabHiddenSinceRef = useRef(new Map()) // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince // survives a background-measure window so per-tab park deadlines stay in @@ -189,7 +195,10 @@ export function useTerminalTabColdParking(args: { parkingEnabled: terminalParkingEnabled, nowMs, parkCooldownUntilMs: measureParkCooldownUntilRef.current, - restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled }, + restorePolicy: { + sshParkingEnabled: terminalSshParkingEnabled, + pairedRuntimeParkingEnvironmentIds + }, ...overrides }) // Why: a tab the byte watchers cannot cover (no capture, no layout @@ -238,6 +247,7 @@ export function useTerminalTabColdParking(args: { assignments, isWorktreeActive, pendingStartupByTabId, + pairedRuntimeParkingEnvironmentIds, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index f98e39cc355..72145641ed4 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -205,10 +205,22 @@ describe('cold activation tab deferral', () => { const tabIds = (count: number): string[] => Array.from({ length: count }, (_, index) => `tab-${index + 1}`) - it('enables deferral only for a positively resolved local execution host', () => { + it('enables deferral for local and snapshot-capable paired execution hosts', () => { expect(canDeferColdActivationTabsForHost({ executionHostId: 'local' })).toBe(true) expect(canDeferColdActivationTabsForHost({ executionHostId: 'ssh:ssh-1' })).toBe(false) expect(canDeferColdActivationTabsForHost({ executionHostId: 'runtime:runtime-1' })).toBe(false) + expect( + canDeferColdActivationTabsForHost({ + executionHostId: 'runtime:runtime-1', + pairedRuntimeParkingEnvironmentIds: new Set(['runtime-1']) + }) + ).toBe(true) + expect( + canDeferColdActivationTabsForHost({ + executionHostId: 'runtime:runtime-1', + pairedRuntimeParkingEnvironmentIds: new Set(['runtime-2']) + }) + ).toBe(false) expect(canDeferColdActivationTabsForHost({ executionHostId: null })).toBe(false) }) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index ae8c677c293..c860046e3e3 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -2,6 +2,7 @@ import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { parseExecutionHostId } from '../../../../shared/execution-host' const pendingMounts = new Map() const requestListeners = new Set<() => void>() @@ -153,10 +154,18 @@ export function canMountTerminalWorkspaceForStartup(args: { export function canDeferColdActivationTabsForHost(args: { executionHostId: string | null + pairedRuntimeParkingEnvironmentIds?: ReadonlySet }): boolean { - // Why: restored identities can be transient or stale while remote ownership - // hydrates. Only positively confirmed local execution has daemon snapshots. - return args.executionHostId === 'local' + const host = parseExecutionHostId(args.executionHostId) + if (host?.kind === 'local') { + return true + } + // Why: remote ownership must match the exact host advertising bounded + // snapshots; stale runtime identities stay eager instead of losing output. + return ( + host?.kind === 'runtime' && + args.pairedRuntimeParkingEnvironmentIds?.has(host.environmentId) === true + ) } function replaceActivationDeferredMountTabs( diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 8ba1de1c55a..524b6697931 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -12,10 +12,12 @@ import { } from './remote-runtime-shared-control-ready' import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' import * as sharedControlSend from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' +import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' @@ -43,6 +45,7 @@ export class RemoteRuntimeSharedControlConnection { private lastError: string | null = null private readonly pendingRequests = new Map>() private readonly subscriptions = new Map>() + private readonly retiredRequestIds = new SharedControlRetiredRequestIds() private readonly readyWaiters: SharedControlReadyWaiter[] = [] private everReady = false private readonly socketGeneration = new SharedControlSocketGeneration() @@ -72,7 +75,8 @@ export class RemoteRuntimeSharedControlConnection { params, timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), - send: (requestId) => this.sendRequest(requestId) + send: (requestId) => this.sendRequest(requestId), + retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId) }) } @@ -203,6 +207,7 @@ export class RemoteRuntimeSharedControlConnection { deviceToken: this.pairing.deviceToken, pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, + retiredRequestIds: this.retiredRequestIds, readyWaiters: this.readyWaiters, setState: (state) => { this.state = state @@ -256,10 +261,12 @@ export class RemoteRuntimeSharedControlConnection { } private closeSubscription(requestId: string): void { - sharedControlSubscriptions.closeSharedControlLogicalSubscription({ + closeSharedControlConnectionSubscription({ subscriptions: this.subscriptions, - subscription: this.subscriptions.get(requestId), - request: (method, params) => this.sendSubscriptionCleanupRequest(method, params) + retiredRequestIds: this.retiredRequestIds, + requestId, + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload) }) this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed') } @@ -273,15 +280,6 @@ export class RemoteRuntimeSharedControlConnection { }) } - private sendSubscriptionCleanupRequest(method: string, params: unknown): void { - sharedControlSubscriptions.sendSharedControlCleanupRequest({ - deviceToken: this.pairing.deviceToken, - method, - params, - send: (payload) => this.sendEncrypted(payload) - }) - } - private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void { if ( !this.socketGeneration.acceptClose({ diff --git a/src/shared/remote-runtime-shared-control-frame-dispatch.ts b/src/shared/remote-runtime-shared-control-frame-dispatch.ts index 8b8cc58d248..20c73b879ed 100644 --- a/src/shared/remote-runtime-shared-control-frame-dispatch.ts +++ b/src/shared/remote-runtime-shared-control-frame-dispatch.ts @@ -1,9 +1,8 @@ import type { parseRemoteRuntimeRpcFrame } from './remote-runtime-request-frames' import { logUnknownSharedControlResponse } from './remote-runtime-shared-control-diagnostics-log' -import { - handleSharedControlLogicalResponse, - sendSharedControlCleanupRequest -} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { sendRetiredSharedControlCleanupRequest } from './remote-runtime-shared-control-subscription-close' +import { handleSharedControlLogicalResponse } from './remote-runtime-shared-control-subscriptions' import { refreshSharedControlPendingRequestTimeouts, resolveSharedControlPendingResponse @@ -20,6 +19,7 @@ export function dispatchSharedControlFrame(args: { frame: SharedControlFrame pendingRequests: Map> subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds deviceToken: string send: (payload: unknown) => boolean }): void { @@ -36,18 +36,27 @@ export function dispatchSharedControlFrame(args: { subscription, response, request: (method, params) => - sendSharedControlCleanupRequest({ + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds: args.retiredRequestIds, deviceToken: args.deviceToken, method, params, send: args.send }) }) + if (!args.subscriptions.has(response.id)) { + args.retiredRequestIds.retire(response.id) + } return } if (args.pendingRequests.has(response.id)) { resolveSharedControlPendingResponse(args.pendingRequests, response.id, response) + args.retiredRequestIds.retire(response.id) + return + } + + if (args.retiredRequestIds.has(response.id)) { return } diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 3c08eb88f1a..3bf3c5cdcee 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -3,6 +3,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch' import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state' import type { SharedControlConnectionState, @@ -19,6 +20,7 @@ export function handleSharedControlTextFrame(args: { environmentId?: string pendingRequests: Map> subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds readyWaiters: SharedControlReadyWaiter[] setState: (state: SharedControlConnectionState) => void handleSocketClosed: (error: RemoteRuntimeClientError) => void @@ -65,6 +67,7 @@ export function handleSharedControlTextFrame(args: { frame: parsed.frame, pendingRequests: args.pendingRequests, subscriptions: args.subscriptions, + retiredRequestIds: args.retiredRequestIds, deviceToken: args.deviceToken, send: args.sendEncrypted }) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index f7a026f745f..8379e53da91 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -21,6 +21,7 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string) => void + retireRequestId?: (requestId: string) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean @@ -48,6 +49,7 @@ export function requestSharedControl(args: { } pendingRequests.delete(requestId) releaseRemoteRuntimePreparedRequest(pending) + args.retireRequestId?.(requestId) // Why: one stalled method does not prove the shared socket is dead; // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) diff --git a/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts b/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts new file mode 100644 index 00000000000..4b3b5a0ef0c --- /dev/null +++ b/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +describe('SharedControlRetiredRequestIds', () => { + it('retains recent ids through repeated late frames and expires them', () => { + let now = 1_000 + const ids = new SharedControlRetiredRequestIds({ + ttlMs: 100, + now: () => now + }) + + ids.retire('request-1') + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-1')).toBe(true) + + now += 101 + expect(ids.has('request-1')).toBe(false) + expect(ids.size).toBe(0) + }) + + it('evicts the oldest ids at its configured bound', () => { + const ids = new SharedControlRetiredRequestIds({ maxIds: 2 }) + + ids.retire('request-1') + ids.retire('request-2') + ids.retire('request-3') + + expect(ids.size).toBe(2) + expect(ids.has('request-1')).toBe(false) + expect(ids.has('request-2')).toBe(true) + expect(ids.has('request-3')).toBe(true) + }) + + it('refreshes an existing id to the newest eviction rank', () => { + const ids = new SharedControlRetiredRequestIds({ maxIds: 2 }) + + ids.retire('request-1') + ids.retire('request-2') + ids.retire('request-1') + ids.retire('request-3') + + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-2')).toBe(false) + expect(ids.has('request-3')).toBe(true) + }) + + it('expires ids correctly after the clock moves backward', () => { + let now = 1_000 + const ids = new SharedControlRetiredRequestIds({ + ttlMs: 100, + now: () => now + }) + + ids.retire('request-1') + now = 900 + ids.retire('request-2') + now = 1_001 + + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-2')).toBe(false) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-retired-request-ids.ts b/src/shared/remote-runtime-shared-control-retired-request-ids.ts new file mode 100644 index 00000000000..37341d361a9 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-retired-request-ids.ts @@ -0,0 +1,53 @@ +const DEFAULT_MAX_RETIRED_REQUEST_IDS = 2_048 +const DEFAULT_RETIRED_REQUEST_ID_TTL_MS = 60_000 + +export class SharedControlRetiredRequestIds { + private readonly ids = new Map() + private readonly maxIds: number + private readonly ttlMs: number + private readonly now: () => number + + constructor( + options: { + maxIds?: number + ttlMs?: number + now?: () => number + } = {} + ) { + this.maxIds = Math.max(1, options.maxIds ?? DEFAULT_MAX_RETIRED_REQUEST_IDS) + this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_RETIRED_REQUEST_ID_TTL_MS) + this.now = options.now ?? Date.now + } + + retire(requestId: string): void { + const now = this.now() + this.pruneExpired(now) + this.ids.delete(requestId) + this.ids.set(requestId, now + this.ttlMs) + while (this.ids.size > this.maxIds) { + const oldestId = this.ids.keys().next().value + if (oldestId === undefined) { + return + } + this.ids.delete(oldestId) + } + } + + has(requestId: string): boolean { + this.pruneExpired(this.now()) + return this.ids.has(requestId) + } + + get size(): number { + this.pruneExpired(this.now()) + return this.ids.size + } + + private pruneExpired(now: number): void { + for (const [requestId, expiresAt] of this.ids) { + if (expiresAt <= now) { + this.ids.delete(requestId) + } + } + } +} diff --git a/src/shared/remote-runtime-shared-control-subscription-close.test.ts b/src/shared/remote-runtime-shared-control-subscription-close.test.ts new file mode 100644 index 00000000000..8020dfb6dad --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscription-close.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { + closeSharedControlConnectionSubscription, + sendRetiredSharedControlCleanupRequest +} from './remote-runtime-shared-control-subscription-close' +import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +describe('shared-control subscription retirement', () => { + it('retires a closed subscription and its cleanup request', () => { + const subscriptions = new Map>() + const subscription = createSharedControlSubscription({ + requestId: 'request-1', + method: 'runtime.clientEvents.subscribe', + params: null, + retainedParamsBytes: 0, + callbacks: { onResponse: vi.fn(), onError: vi.fn() } + }) + subscription.sent = true + subscription.remoteSubscriptionId = 'subscription-1' + subscriptions.set(subscription.requestId, subscription) + const retiredRequestIds = new SharedControlRetiredRequestIds() + let cleanupRequestId = '' + + closeSharedControlConnectionSubscription({ + subscriptions, + retiredRequestIds, + requestId: subscription.requestId, + deviceToken: 'device-token', + send: (payload) => { + cleanupRequestId = (payload as { id: string }).id + return true + } + }) + + expect(subscriptions.size).toBe(0) + expect(retiredRequestIds.has(subscription.requestId)).toBe(true) + expect(retiredRequestIds.has(cleanupRequestId)).toBe(true) + }) + + it('does not retire an unsent cleanup request', () => { + const retiredRequestIds = new SharedControlRetiredRequestIds() + let cleanupRequestId = '' + + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds, + deviceToken: 'device-token', + method: 'runtime.clientEvents.unsubscribe', + params: null, + send: (payload) => { + cleanupRequestId = (payload as { id: string }).id + return false + } + }) + + expect(retiredRequestIds.has(cleanupRequestId)).toBe(false) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-subscription-close.ts b/src/shared/remote-runtime-shared-control-subscription-close.ts new file mode 100644 index 00000000000..7ba60c9d624 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscription-close.ts @@ -0,0 +1,44 @@ +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { + closeSharedControlLogicalSubscription, + sendSharedControlCleanupRequest +} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +export function closeSharedControlConnectionSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean +}): void { + const subscription = args.subscriptions.get(args.requestId) + closeSharedControlLogicalSubscription({ + subscriptions: args.subscriptions, + subscription, + request: (method, params) => + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds: args.retiredRequestIds, + deviceToken: args.deviceToken, + method, + params, + send: args.send + }) + }) + if (subscription && !args.subscriptions.has(args.requestId)) { + args.retiredRequestIds.retire(args.requestId) + } +} + +export function sendRetiredSharedControlCleanupRequest(args: { + retiredRequestIds: SharedControlRetiredRequestIds + deviceToken: string + method: string + params: unknown + send: (payload: unknown) => boolean +}): void { + const requestId = sendSharedControlCleanupRequest(args) + if (requestId) { + args.retiredRequestIds.retire(requestId) + } +} diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 5b18ce801f0..4a93076d23f 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -86,15 +86,17 @@ export function sendSharedControlCleanupRequest(args: { method: string params: unknown send: (payload: unknown) => boolean -}): void { +}): string | null { // Why: cleanup is best-effort and often runs during teardown; send it // synchronously so close() cannot race the async request path. - args.send({ - id: randomUUID(), + const requestId = randomUUID() + const sent = args.send({ + id: requestId, deviceToken: args.deviceToken, method: args.method, params: args.params }) + return sent ? requestId : null } export function replaySharedControlSubscriptions(args: { diff --git a/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts b/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts index fcb462d4c6c..2211328b50c 100644 --- a/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts +++ b/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { launchPairedWebClient, type PairedWebClient } from './helpers/paired-electron-client' +import { runPairedTerminalColdActivationOracle } from './helpers/paired-terminal-cold-activation-oracle' import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle' -test('ordinary-parks paired terminals against an isolated headless Orca host @headful', async ({ +test('ordinary-parks paired terminals against an isolated headless Orca host', async ({ testRepoPath }) => { test.setTimeout(240_000) @@ -38,3 +39,35 @@ test('ordinary-parks paired terminals against an isolated headless Orca host @he await host.dispose() } }) + +test('cold-activates only visible paired terminals against an isolated headless host', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedWebClient | null = null + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + await expect + .poll( + async () => { + const listed = await host.client.call<{ totalCount: number }>('worktree.list', { + repo: `id:${added.result.repo.id}` + }) + return listed.result.totalCount + }, + { timeout: 30_000 } + ) + .toBeGreaterThan(0) + client = await launchPairedWebClient(host.app, host.offer, { + terminalParkingDelayMs: 100 + }) + await runPairedTerminalColdActivationOracle(client.page, { repoId: added.result.repo.id }) + } finally { + await client?.dispose() + await host.dispose() + } +}) diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 926c4bf5f82..83186e5a7c7 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -21,6 +21,10 @@ type ServeReady = { } } +const STARTUP_DIAGNOSTIC_LIMIT = 8_000 +const PAIRING_URL_PATTERN = /orca:\/\/[^\s"\\]+/g +const WEB_CLIENT_PAIRING_PATTERN = /([#&]pairing=)[^&\s"\\]+/g + export type HeadlessPairedRuntimeHost = { app: ElectronApplication client: RuntimeClient @@ -28,6 +32,62 @@ export type HeadlessPairedRuntimeHost = { offer: RuntimeDesktopPairingOffer } +export class HeadlessPairedRuntimeStartupDiagnosticBuffer { + private completed = '' + private discardingOversizedLine = false + private pending = '' + + append(chunk: Buffer): void { + let value = chunk.toString() + if (this.discardingOversizedLine) { + const newlineIndex = value.indexOf('\n') + if (newlineIndex === -1) { + return + } + value = value.slice(newlineIndex + 1) + this.discardingOversizedLine = false + } + + const combined = `${this.pending}${value}` + const newlineIndex = combined.lastIndexOf('\n') + if (newlineIndex !== -1) { + this.completed = `${this.completed}${redactPairingMaterial( + combined.slice(0, newlineIndex + 1) + )}`.slice(-STARTUP_DIAGNOSTIC_LIMIT) + this.pending = combined.slice(newlineIndex + 1) + } else { + this.pending = combined + } + if (this.pending.length > STARTUP_DIAGNOSTIC_LIMIT) { + this.pending = '' + this.discardingOversizedLine = true + } + } + + read(): string { + const pending = this.discardingOversizedLine ? '' : redactPairingMaterial(this.pending) + return `${this.completed}${pending}`.slice(-STARTUP_DIAGNOSTIC_LIMIT) + } +} + +export function formatHeadlessPairedRuntimeStartupDiagnostics( + stdout: string, + stderr: string +): string { + return [ + stdout ? `stdout:\n${redactPairingMaterial(stdout)}` : '', + stderr ? `stderr:\n${redactPairingMaterial(stderr)}` : '' + ] + .filter(Boolean) + .join('\n') +} + +function redactPairingMaterial(value: string): string { + return value + .replace(PAIRING_URL_PATTERN, 'orca://[redacted]') + .replace(WEB_CLIENT_PAIRING_PATTERN, '$1[redacted]') +} + async function readPairingOffer(app: ElectronApplication): Promise { const child = app.process() const stdout = child.stdout @@ -36,24 +96,44 @@ async function readPairingOffer(app: ElectronApplication): Promise { let buffered = '' + const stdoutDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + const stderrDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + const stderr = child.stderr const timeout = setTimeout(() => { cleanup() - reject(new Error('Headless runtime did not publish pairing readiness')) + const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics( + stdoutDiagnostic.read(), + stderrDiagnostic.read() + ) + reject( + new Error( + `Headless runtime did not publish pairing readiness${diagnostics ? `\n${diagnostics}` : ''}` + ) + ) }, 60_000) const cleanup = (): void => { clearTimeout(timeout) stdout.off('data', onData) + stderr?.off('data', onStderr) child.off('close', onClose) } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { cleanup() + const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics( + stdoutDiagnostic.read(), + stderrDiagnostic.read() + ) reject( new Error( - `Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})` + `Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})${diagnostics ? `\n${diagnostics}` : ''}` ) ) } + const onStderr = (chunk: Buffer): void => { + stderrDiagnostic.append(chunk) + } const onData = (chunk: Buffer): void => { + stdoutDiagnostic.append(chunk) buffered += chunk.toString() const lines = buffered.split(/\r?\n/) buffered = lines.pop() ?? '' @@ -79,6 +159,7 @@ async function readPairingOffer(app: ElectronApplication): Promise electronApp.getPath('home')), - isolation - ) - const offer = await readPairingOffer(app) + const [offer] = await Promise.all([ + readPairingOffer(app), + app + .evaluate(({ app: electronApp }) => electronApp.getPath('home')) + .then((home) => assertElectronResolvedIsolatedHome(home, isolation)) + ]) return { app, client: new RuntimeClient(userDataDir, 5_000), diff --git a/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts b/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts new file mode 100644 index 00000000000..69e7e0a668e --- /dev/null +++ b/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + HeadlessPairedRuntimeStartupDiagnosticBuffer, + formatHeadlessPairedRuntimeStartupDiagnostics +} from './headless-paired-runtime-host' + +describe('headless paired runtime startup diagnostics', () => { + it('redacts pairing URLs before truncation can remove their prefix', () => { + const pairingUrl = `orca://${'secret'.repeat(1_500)}` + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + + diagnostic.append(Buffer.from(`prefix${pairingUrl}\n`)) + + expect(diagnostic.read()).toBe('prefixorca://[redacted]\n') + expect(diagnostic.read()).not.toContain('secret') + }) + + it('redacts pairing URLs split across chunks', () => { + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + diagnostic.append(Buffer.from('orca://p')) + diagnostic.append(Buffer.from('airing-secret\nready')) + + expect(formatHeadlessPairedRuntimeStartupDiagnostics(diagnostic.read(), '')).toBe( + 'stdout:\norca://[redacted]\nready' + ) + }) + + it('redacts encoded pairing material from web-client URLs', () => { + const pairingUrl = encodeURIComponent('orca://pairing-secret') + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + + diagnostic.append(Buffer.from(`https://host/web-index.html#pairing=${pairingUrl}\n`)) + + expect(diagnostic.read()).toBe('https://host/web-index.html#pairing=[redacted]\n') + expect(diagnostic.read()).not.toContain('pairing-secret') + }) + + it('drops oversized unfinished lines instead of retaining a pairing fragment', () => { + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + diagnostic.append(Buffer.from(`orca://${'secret'.repeat(1_500)}`)) + diagnostic.append(Buffer.from('still-secret\nsafe')) + + expect(diagnostic.read()).toBe('safe') + }) +}) diff --git a/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts b/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts new file mode 100644 index 00000000000..04d27a736f7 --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts @@ -0,0 +1,55 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +export async function callColdActivationRuntime( + page: Page, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function readColdActivationMountState( + page: Page, + tabIds: string[] +): Promise<{ mounted: number; parked: number }> { + return page.evaluate((targets) => { + const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? []) + return { + mounted: targets.filter((id) => window.__paneManagers?.has(id)).length, + parked: targets.filter((id) => parked.has(id)).length + } + }, tabIds) +} + +export async function expectStableColdActivationMountState( + page: Page, + tabIds: string[], + expected: { mounted: number; parked: number } +): Promise { + await expect + .poll(() => readColdActivationMountState(page, tabIds), { timeout: 30_000 }) + .toEqual(expected) + const samples = await page.evaluate(async (targets) => { + const result: { mounted: number; parked: number }[] = [] + for (let index = 0; index < 8; index += 1) { + const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? []) + result.push({ + mounted: targets.filter((id) => window.__paneManagers?.has(id)).length, + parked: targets.filter((id) => parked.has(id)).length + }) + await new Promise((resolve) => window.setTimeout(resolve, 25)) + } + return result + }, tabIds) + expect(samples).toEqual(Array.from({ length: 8 }, () => expected)) +} diff --git a/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts b/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts new file mode 100644 index 00000000000..f11bb6e284a --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts @@ -0,0 +1,292 @@ +import type { Page } from '@stablyai/playwright-test' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' +import { expect } from './orca-app' +import { + callColdActivationRuntime, + expectStableColdActivationMountState, + readColdActivationMountState +} from './paired-terminal-cold-activation-observation' +import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' +import { getTerminalContent, waitForActivePanePtyId } from './terminal' + +const TARGET_TAB_COUNT = 8 + +type ColdTab = { + marker: string + originalPtyId: string + tabId: string + terminal: string +} + +export async function runPairedTerminalColdActivationOracle( + page: Page, + seed: { repoId: string } +): Promise { + const fixture = createPairedTerminalParkingFixture() + const handles: string[] = [] + const createdWorktreeIds: string[] = [] + let fallbackWorktreeId: string | null = null + let worktreeId: string | null = null + try { + await expect + .poll( + () => + page.evaluate((capability) => { + const state = window.__store?.getState() + const statuses = Array.from(state?.runtimeStatusByEnvironmentId.entries() ?? []) + return JSON.stringify({ + capable: statuses.some(([, entry]) => + entry.status?.capabilities?.includes(capability) + ), + statuses: statuses.map(([environmentId, entry]) => ({ + capabilities: entry.status?.capabilities ?? [], + environmentId, + hasStatus: entry.status != null + })), + workspaces: state?.allWorktrees().length ?? 0, + workspaceSessionReady: state?.workspaceSessionReady ?? false + }) + }, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY), + { timeout: 30_000 } + ) + .toContain('"capable":true') + + const fallback = await callColdActivationRuntime<{ + startupTerminal?: { handle?: string; tabId?: string } + worktree: { id: string } + }>(page, 'worktree.create', { + repo: seed.repoId, + name: `paired-cold-fallback-${Date.now()}`, + setupDecision: 'skip', + activate: false, + noParent: true, + startupCommand: fixture.command('PAIR_COLD_FALLBACK') + }) + fallbackWorktreeId = fallback.worktree.id + createdWorktreeIds.push(fallbackWorktreeId) + if (!fallback.startupTerminal?.handle || !fallback.startupTerminal.tabId) { + throw new Error('Paired cold-activation fallback terminal was not created') + } + handles.push(fallback.startupTerminal.handle) + const fallbackTabId = toWebTerminalSurfaceTabId(fallback.startupTerminal.tabId) + await page.evaluate( + ({ tabId, targetWorktreeId }) => { + const state = window.__store?.getState() + state?.setActiveTabForWorktree(targetWorktreeId, tabId) + state?.setActiveView('terminal') + state?.setActiveWorktree(targetWorktreeId) + }, + { tabId: fallbackTabId, targetWorktreeId: fallbackWorktreeId } + ) + const fallbackTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${fallbackTabId}"]`) + await expect(fallbackTab).toBeVisible({ timeout: 30_000 }) + await fallbackTab.click() + await expect(fallbackTab).toHaveAttribute('data-active', 'true') + + const firstMarker = 'PAIR_COLD_ACTIVATION_0' + const created = await callColdActivationRuntime<{ + startupTerminal?: { handle?: string; tabId?: string } + worktree: { id: string } + }>(page, 'worktree.create', { + repo: seed.repoId, + name: `paired-cold-activation-${Date.now()}`, + setupDecision: 'skip', + activate: false, + noParent: true, + startupCommand: fixture.command(firstMarker) + }) + worktreeId = created.worktree.id + createdWorktreeIds.push(worktreeId) + if (!created.startupTerminal?.handle || !created.startupTerminal.tabId) { + throw new Error('Paired cold-activation startup terminal was not created') + } + handles.push(created.startupTerminal.handle) + const pendingTabs = [ + { + marker: firstMarker, + tabId: toWebTerminalSurfaceTabId(created.startupTerminal.tabId), + terminal: created.startupTerminal.handle + } + ] + + while (pendingTabs.length < TARGET_TAB_COUNT) { + const marker = `PAIR_COLD_ACTIVATION_${pendingTabs.length}` + const result = await callColdActivationRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixture.command(marker), + activate: false, + select: false, + navigation: 'caller' + }) + if (!result.tab.terminal) { + throw new Error(`Paired cold-activation terminal ${pendingTabs.length} was not created`) + } + handles.push(result.tab.terminal) + pendingTabs.push({ + marker, + tabId: toWebTerminalSurfaceTabId(result.tab.parentTabId), + terminal: result.tab.terminal + }) + } + + let originalPtyIds: string[] | null = null + await expect + .poll( + async () => { + originalPtyIds = await page.evaluate( + ({ tabIds, targetWorktreeId }) => { + const tabs = window.__store?.getState().tabsByWorktree[targetWorktreeId] ?? [] + const byId = new Map(tabs.map((tab) => [tab.id, tab.ptyId])) + const ids = tabIds.map((id) => byId.get(id) ?? null) + return ids.every((id): id is string => typeof id === 'string') ? ids : null + }, + { + tabIds: pendingTabs.map((tab) => tab.tabId), + targetWorktreeId: worktreeId + } + ) + return originalPtyIds + }, + { timeout: 30_000 } + ) + .not.toBeNull() + if (originalPtyIds === null) { + throw new Error('Paired cold-activation PTY ids were not captured') + } + const tabs: ColdTab[] = pendingTabs.map((tab, index) => ({ + ...tab, + originalPtyId: originalPtyIds[index]! + })) + const tabIds = tabs.map((tab) => tab.tabId) + expect(await readColdActivationMountState(page, tabIds)).toEqual({ mounted: 0, parked: 0 }) + + await page.evaluate( + ({ activeTabId, targetWorktreeId }) => { + const state = window.__store?.getState() + state?.setActiveTabForWorktree(targetWorktreeId, activeTabId) + state?.setActiveView('terminal') + state?.setActiveWorktree(targetWorktreeId) + }, + { activeTabId: tabs[0].tabId, targetWorktreeId: worktreeId } + ) + const firstTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[0].tabId}"]`) + await expect(firstTab).toBeVisible({ timeout: 30_000 }) + await expect(firstTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 1, + parked: TARGET_TAB_COUNT - 1 + }) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(tabs[0].originalPtyId) + + const deferred = tabs[4] + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expectStableColdActivationMountState(page, tabIds, { + mounted: TARGET_TAB_COUNT, + parked: 0 + }) + const deferredTab = page.locator( + `[data-testid="sortable-tab"][data-tab-id="${deferred.tabId}"]` + ) + await deferredTab.click() + await expect(deferredTab).toHaveAttribute('data-active', 'true') + expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`READY:${deferred.marker}`) + await firstTab.click() + await expect(firstTab).toHaveAttribute('data-active', 'true') + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalHiddenViewParking: true }) + }) + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + await page.evaluate( + (fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId), + fallbackWorktreeId + ) + await expect(firstTab).not.toBeVisible() + await page.evaluate( + (targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId), + worktreeId + ) + await expect(firstTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + const secondTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[1].tabId}"]`) + await secondTab.click() + await expect(secondTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + await page.evaluate( + (fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId), + fallbackWorktreeId + ) + await expect(secondTab).not.toBeVisible() + await page.evaluate( + (targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId), + worktreeId + ) + await expect(secondTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + const deferredMarker = `PAIR_COLD_DEFERRED_${Date.now()}` + const sent = await callColdActivationRuntime<{ send: { accepted: boolean } }>( + page, + 'terminal.send', + { + terminal: deferred.terminal, + text: deferredMarker, + enter: true + } + ) + expect(sent.send.accepted).toBe(true) + + await deferredTab.click() + await expect(deferredTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`READY:${deferred.marker}`) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`LIVE:${deferredMarker}`) + } finally { + for (const terminal of handles) { + await callColdActivationRuntime(page, 'terminal.closeTab', { terminal }).catch( + () => undefined + ) + } + await page + .evaluate(() => window.__store?.getState().setActiveWorktree(null)) + .catch(() => undefined) + for (const createdWorktreeId of createdWorktreeIds.toReversed()) { + await callColdActivationRuntime(page, 'worktree.rm', { + worktree: `id:${createdWorktreeId}`, + force: true, + runHooks: false + }).catch(() => undefined) + } + fixture.dispose() + } +} From 11936f08ab1bb736a8f53e215cea5da3b2793793 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:25:52 -0700 Subject: [PATCH 054/123] perf(renderer): lazy load Linear setup dialog (#11687) --- src/renderer/src/app-startup-routing.test.ts | 12 ++ .../sidebar/LinearAgentSkillSetupDialog.tsx | 2 + ...ntSkillSetupPrompt.reminder-toast.test.tsx | 1 + .../LinearAgentSkillSetupPrompt.test.tsx | 15 +- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 132 +++++++++--------- ...ntSkillSetupPrompt.update-command.test.tsx | 1 + 6 files changed, 93 insertions(+), 70 deletions(-) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index f638ae82083..8547e17a135 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -328,6 +328,18 @@ describe('renderer startup runtime routing', () => { ) }) + it('loads Linear agent setup implementation only after the prompt opens it', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'), + 'utf8' + ) + + expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')") + expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") + expect(source).toContain('const setupDialog = setupDialogOpen ? (') + expect(source).toContain('') + }) + it('does not eagerly import optional status-bar segments on startup', () => { const source = readFileSync( join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'), diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx index e67b946cff9..1de868ec200 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx @@ -191,3 +191,5 @@ export function LinearAgentSkillSetupDialog({ ) } + +export default LinearAgentSkillSetupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index d05496a19fe..72fcede0b7b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -119,6 +119,7 @@ async function renderPrompt( await act(async () => { root?.render() }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 0d784cb1033..c26f7024a9a 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -147,6 +147,9 @@ async function renderPrompt( await act(async () => { root?.render() }) + if (props.surface === 'modal') { + await import('./LinearAgentSkillSetupDialog') + } await act(async () => {}) return container } @@ -178,6 +181,7 @@ function findBodyButton(label: string): HTMLButtonElement | undefined { } async function settleRender(): Promise { + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) await act(async () => {}) } @@ -253,12 +257,7 @@ describe('LinearAgentSkillSetupPrompt', () => { const unlinked = await renderPrompt({ linked: false, remote: false }) expect(unlinked.textContent).not.toContain('Set up Linear agent skill') - await act(async () => { - root?.unmount() - }) - root = null - unlinked.remove() - container = null + await unmountPrompt() const ready = await renderPrompt({ linked: true, remote: false }) expect(ready.textContent).not.toContain('Set up Linear agent skill') @@ -343,6 +342,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add") expect(mocks.panelProps.at(-1)).toEqual( @@ -425,7 +425,7 @@ describe('LinearAgentSkillSetupPrompt', () => { expect(mocks.getWslCliStatus).not.toHaveBeenCalled() }) - it('opens the terminal setup panel in a dialog only after the user asks to set up', async () => { + it('keeps the prompt usable and loads the lazy setup dialog only when requested', async () => { const rendered = await renderPrompt({ linked: true, remote: false }) expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).toBeNull() @@ -436,6 +436,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).not.toBeNull() expect(document.body.textContent).toContain('orca-linear') diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index d840807a7e6..31c648da74b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RefreshCw, TicketCheck, X } from 'lucide-react' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' @@ -16,6 +16,7 @@ import { ensureOrcaCliAvailableForAgentSkillTerminal, isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { cn } from '@/lib/utils' import { buildSkillCommandForRuntime, @@ -45,9 +46,12 @@ import { readLocalDismissed, type LinearAgentSkillPromptSettings } from './linear-agent-skill-runtime' -import { LinearAgentSkillSetupDialog } from './LinearAgentSkillSetupDialog' import { translate } from '@/i18n/i18n' +const LinearAgentSkillSetupDialog = lazyWithRetry(() => import('./LinearAgentSkillSetupDialog'), { + reloadKey: 'linear-agent-skill-setup-dialog' +}) + export const _linearAgentSkillSetupPromptInternalsForTests = { resetSessionReminders(): void { resetLinearAgentSkillSetupReminderToastState() @@ -278,69 +282,71 @@ export function LinearAgentSkillSetupPrompt({ return null } - const setupDialog = ( - window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) - : undefined - } - onBeforeOpenTerminal={async () => { - const requestIdentity = setupCheckIdentity - const writeIfCurrent = (write: () => void): void => { - writeCliStatusForIdentity(requestIdentity, write) - } - const nextStatus = + const setupDialog = setupDialogOpen ? ( + + { - writeIfCurrent(() => setCliStatus(nextCliStatus)) - } - }) - if (agentRuntime.runtime === 'wsl') { - writeIfCurrent(() => setCliStatus(nextStatus)) + ? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) + : undefined } - }} - onRecheck={async () => { - if (surface === 'modal') { - setActiveSetupCheckIdentity(setupCheckIdentity) - setSetupCheckResult('checking') - await Promise.all([refreshCliStatus(), skill.refresh()]) - return - } - await refreshCliStatus() - await skill.refresh() - }} - onOpenChange={(open) => { - if (open) { - setSetupDialogOpen(true) - return - } - if (showSuccessModal) { - closeSuccessModal() - return - } - if (surface === 'modal') { - snoozeForSession() - return - } - setSetupDialogOpen(false) - }} - onDismissPermanently={dismissPermanently} - onDone={closeSuccessModal} - /> - ) + onBeforeOpenTerminal={async () => { + const requestIdentity = setupCheckIdentity + const writeIfCurrent = (write: () => void): void => { + writeCliStatusForIdentity(requestIdentity, write) + } + const nextStatus = + agentRuntime.runtime === 'wsl' + ? await ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) + : await ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: (nextCliStatus) => { + writeIfCurrent(() => setCliStatus(nextCliStatus)) + } + }) + if (agentRuntime.runtime === 'wsl') { + writeIfCurrent(() => setCliStatus(nextStatus)) + } + }} + onRecheck={async () => { + if (surface === 'modal') { + setActiveSetupCheckIdentity(setupCheckIdentity) + setSetupCheckResult('checking') + await Promise.all([refreshCliStatus(), skill.refresh()]) + return + } + await refreshCliStatus() + await skill.refresh() + }} + onOpenChange={(open) => { + if (open) { + setSetupDialogOpen(true) + return + } + if (showSuccessModal) { + closeSuccessModal() + return + } + if (surface === 'modal') { + snoozeForSession() + return + } + setSetupDialogOpen(false) + }} + onDismissPermanently={dismissPermanently} + onDone={closeSuccessModal} + /> + + ) : null if (surface === 'modal') { return setupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx index 7ff473b49da..af2c3f85572 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx @@ -107,6 +107,7 @@ async function renderPrompt( ) }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } From 037f7a07d30ca25a68764e074ec2f448049ca8f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:29:01 -0700 Subject: [PATCH 055/123] fix(mobile): open host editor reliably (#11635) --- mobile/app/index.tsx | 3 +- .../transport/host-edit-navigation.test.ts | 29 +++++++++++++++++++ mobile/src/transport/host-edit-navigation.ts | 19 ++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 mobile/src/transport/host-edit-navigation.test.ts create mode 100644 mobile/src/transport/host-edit-navigation.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index f486fe99c78..4000906c933 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -26,6 +26,7 @@ import { } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' +import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' @@ -1010,7 +1011,7 @@ export default function HomeScreen() { closeBeforePress: true, onPress: () => { setActionTarget(null) - router.push(`/h/${host.id}/edit`) + navigateToMobileHostEdit(router, host.id) } }) items.push({ diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts new file mode 100644 index 00000000000..66ca63c5efa --- /dev/null +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation' + +describe('mobileHostEditRoute', () => { + it('keeps the dynamic host segment explicit for a cold host navigator', () => { + expect(mobileHostEditRoute('host-1')).toEqual({ + pathname: '/h/[hostId]/edit', + params: { hostId: 'host-1' } + }) + }) + + it('mounts a cold host navigator before replacing its index with edit', () => { + let nextFrame: FrameRequestCallback | null = null + const push = vi.fn() + const replace = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + nextFrame = callback + return 1 + }) + + navigateToMobileHostEdit({ push, replace }, 'host-1') + expect(push).toHaveBeenCalledWith('/h/host-1') + expect(replace).not.toHaveBeenCalled() + + nextFrame?.(0) + expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1')) + vi.unstubAllGlobals() + }) +}) diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts new file mode 100644 index 00000000000..19dbf703dec --- /dev/null +++ b/mobile/src/transport/host-edit-navigation.ts @@ -0,0 +1,19 @@ +type HostEditRouter = { + push: (href: `/h/${string}`) => void + replace: (href: ReturnType) => void +} + +export function mobileHostEditRoute(hostId: string) { + return { + pathname: '/h/[hostId]/edit' as const, + params: { hostId } + } +} + +export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void { + // Why: a cold nested host navigator resolves a deep push to its index route. + router.push(`/h/${hostId}`) + requestAnimationFrame(() => { + router.replace(mobileHostEditRoute(hostId)) + }) +} From fe979a402a12a05115b83ff7c2ec60fe74e2bac1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 31 Jul 2026 00:36:09 -0700 Subject: [PATCH 056/123] Fix SSH port-forward rows disappearing after hydration (#11713) --- config/reliability-gates.jsonc | 135 ++++++- src/renderer/src/hooks/useIpcEvents.test.ts | 300 ++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 62 ++- .../ssh-port-forward-lifecycle-evidence.ts | 225 +++++++++++ .../ssh-port-forward-snapshot-barrier.ts | 158 ++++++++ ...port-forward-snapshot-barrier.unit.test.ts | 69 ++++ .../ssh-port-forward-transport-evidence.ts | 145 +++++++ tests/e2e/ssh-port-forward-lifecycle.spec.ts | 370 ++++++++++++++++++ 8 files changed, 1445 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-transport-evidence.ts create mode 100644 tests/e2e/ssh-port-forward-lifecycle.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a243d500d0b..fb86c790432 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-29", + "updatedAt": "2026-07-30", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -7879,6 +7879,139 @@ "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default." ], "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge." + }, + { + "id": "ssh-port-forward.renderer-snapshot-continuity", + "title": "SSH forwarded-port state survives stale renderer hydration", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-ssh", + "layer": "renderer-ssh-snapshot-reconciliation", + "surfaces": [ + "SSH Ports panel", + "forwarded-port renderer state", + "persisted forward restoration", + "ssh2 port forwarding", + "system-SSH port forwarding" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh2", "system-ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh2", "system-ssh"], + "coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.", + "motivatingLinks": ["user-reported SSH Ports panel disappearance"], + "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", + "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + ], + "testFiles": [ + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/main/ssh/ssh-port-forward.test.ts", + "src/main/ssh/system-ssh-forward-process.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/ssh/ssh-relay-session.test.ts", + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "tests/e2e/ssh-port-forward-lifecycle.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useIpcEvents.test.ts", + "assertions": ["does not let initial SSH port snapshots overwrite newer push events"] + }, + { + "file": "src/main/ssh/ssh-port-forward.test.ts", + "assertions": ["lists forwards filtered by connectionId", "removes a forward by id"] + }, + { + "file": "src/main/ssh/system-ssh-forward-process.test.ts", + "assertions": [ + "does not spawn ssh when the requested local forward port is already in use", + "sends SIGTERM then SIGKILL when the process does not exit", + "does not resolve stop until the process exits" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "preserves active port forwards and live connections across handler re-registration", + "persists desired forwards and broadcasts when an active forward closes unexpectedly" + ] + }, + { + "file": "src/main/ssh/ssh-relay-session.test.ts", + "assertions": ["cleans up port forwards on reconnect"] + }, + { + "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "assertions": ["holds only the first matching request while its snapshot is unresolved"] + }, + { + "file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts", + "assertions": ["keeps a user-forwarded listener live across scan refresh @headful"] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 6.77, + "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 53.7, + "summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 66, + "summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "focused renderer/main contracts plus two headed Docker SSH transport runs" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate." + }, + "performanceBudget": { + "required": true, + "evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the headed topology on Linux and Windows with their native system-SSH clients.", + "Re-run the headed snapshot barrier after Electron major-version upgrades.", + "Keep the independent Forwarded and Detected freshness assertions and both transport runs green." + ], + "knownGaps": [ + "Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.", + "The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.", + "The live run reconnects an existing app session but does not restart the packaged application from disk.", + "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." + ], + "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." } ] } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1f9b1763851..cfc4bd5d8fc 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -5539,6 +5539,306 @@ describe('useIpcEvents agent status snapshot integration', () => { } ) + it('does not let initial SSH port snapshots overwrite newer push events', async () => { + const targetId = 'target-ports' + const secondTargetId = 'target-ports-second' + const rejectingTargetId = 'target-ports-rejecting' + const partialTargetId = 'target-ports-partial' + const connectedState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-ports', + connectionGeneration: 3 + } + const secondConnectedState = { + ...connectedState, + targetId: secondTargetId, + providerEpoch: 'epoch-ports-second' + } + const rejectingConnectedState = { + ...connectedState, + targetId: rejectingTargetId, + providerEpoch: 'epoch-ports-rejecting' + } + const partialConnectedState = { + ...connectedState, + targetId: partialTargetId, + providerEpoch: null, + connectionGeneration: undefined + } + const reconciledPartialState = { + ...connectedState, + targetId: partialTargetId, + providerEpoch: 'epoch-ports-partial', + connectionGeneration: 4 + } + const liveForward = { + id: 'forward-live', + targetId, + localPort: 17860, + remoteHost: '127.0.0.1', + remotePort: 7860, + status: 'active' as const + } + const secondForward = { + ...liveForward, + id: 'forward-second', + targetId: secondTargetId, + localPort: 17861, + remotePort: 7861 + } + const rejectingTargetForward = { + ...liveForward, + id: 'forward-rejecting-target', + targetId: rejectingTargetId, + localPort: 17862, + remotePort: 7862 + } + const partialTargetForward = { + ...liveForward, + id: 'forward-partial-target', + targetId: partialTargetId, + localPort: 17863, + remotePort: 7863 + } + const detectedPort = { + port: 7860, + pid: 42, + processName: 'python', + command: 'python -m http.server 7860' + } + const secondDetectedPort = { + ...detectedPort, + port: 7861, + pid: 43, + command: 'python -m http.server 7861' + } + let resolveForwards: (value: []) => void = () => {} + let resolveDetected: (value: (typeof detectedPort)[]) => void = () => {} + let resolveSecondForwards: (value: (typeof secondForward)[]) => void = () => {} + let resolveSecondDetected: (value: []) => void = () => {} + const forwardsSnapshot = new Promise<[]>((resolve) => { + resolveForwards = resolve + }) + const detectedSnapshot = new Promise<(typeof detectedPort)[]>((resolve) => { + resolveDetected = resolve + }) + const secondForwardsSnapshot = new Promise<(typeof secondForward)[]>((resolve) => { + resolveSecondForwards = resolve + }) + const secondDetectedSnapshot = new Promise<[]>((resolve) => { + resolveSecondDetected = resolve + }) + const listPortForwards = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === rejectingTargetId) { + return Promise.resolve([rejectingTargetForward]) + } + if (requestedTargetId === partialTargetId) { + return Promise.resolve([partialTargetForward]) + } + return requestedTargetId === targetId ? forwardsSnapshot : secondForwardsSnapshot + }) + const listDetectedPorts = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === rejectingTargetId) { + return Promise.reject(new Error('detected snapshot unavailable')) + } + if (requestedTargetId === partialTargetId) { + return Promise.resolve([]) + } + return requestedTargetId === targetId ? detectedSnapshot : secondDetectedSnapshot + }) + let forwardListener: + | ((data: { targetId: string; forwards: (typeof liveForward)[] }) => void) + | undefined + let detectedListener: + | ((data: { targetId: string; ports: (typeof detectedPort)[] }) => void) + | undefined + const setPortForwards = vi.fn() + const setDetectedPorts = vi.fn() + const sshConnectionStates = new Map< + string, + | typeof connectedState + | typeof secondConnectedState + | typeof rejectingConnectedState + | typeof partialConnectedState + | typeof reconciledPartialState + >() + const storeState = buildStoreState({ + sshTargetLabels: new Map([ + [targetId, 'Ports Target'], + [secondTargetId, 'Second Ports Target'], + [rejectingTargetId, 'Rejecting Ports Target'], + [partialTargetId, 'Partial Ports Target'] + ]), + sshConnectionStates, + setSshConnectionState: ( + nextTargetId: string, + state: + | typeof connectedState + | typeof secondConnectedState + | typeof rejectingConnectedState + | typeof partialConnectedState + | typeof reconciledPartialState + ) => { + sshConnectionStates.set(nextTargetId, state) + }, + setPortForwards, + setDetectedPorts, + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + fetchRuntimeEnvironmentRepos: vi.fn(async () => []), + fetchWorktreeLineage: vi.fn(async () => undefined), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn(), + invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), + retryDirectSshTargetPanes: vi.fn(() => 0) + }) + const coordinator = { + requestReconnect: vi.fn(async () => ({ status: 'complete' })), + replaceAuthority: vi.fn(), + prepareOnly: vi.fn(async () => ({ token: null })), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + let partialTargetStateCalls = 0 + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => true + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput: vi.fn(), + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + listTargets: () => + Promise.resolve([ + { id: rejectingTargetId, label: 'Rejecting Ports Target' }, + { id: partialTargetId, label: 'Partial Ports Target' }, + { id: targetId, label: 'Ports Target' }, + { id: secondTargetId, label: 'Second Ports Target' } + ]), + listRemovedTargetLabels: () => Promise.resolve({}), + getState: ({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === partialTargetId) { + partialTargetStateCalls += 1 + return Promise.resolve( + partialTargetStateCalls === 1 ? partialConnectedState : reconciledPartialState + ) + } + return Promise.resolve( + requestedTargetId === rejectingTargetId + ? rejectingConnectedState + : requestedTargetId === targetId + ? connectedState + : secondConnectedState + ) + }, + listPortForwards, + listDetectedPorts, + onPortForwardsChanged: ( + listener: (data: { targetId: string; forwards: (typeof liveForward)[] }) => void + ) => { + forwardListener = listener + return () => {} + }, + onDetectedPortsChanged: ( + listener: (data: { targetId: string; ports: (typeof detectedPort)[] }) => void + ) => { + detectedListener = listener + return () => {} + } + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect(forwardListener).toBeTypeOf('function') + expect(detectedListener).toBeTypeOf('function') + expect(partialTargetStateCalls).toBe(2) + expect(sshConnectionStates.get(partialTargetId)).toEqual(reconciledPartialState) + expect(listPortForwards).toHaveBeenCalledTimes(4) + expect(listDetectedPorts).toHaveBeenCalledTimes(4) + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === rejectingTargetId + ) + ).toEqual([[rejectingTargetId, [rejectingTargetForward]]]) + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === partialTargetId + ) + ).toEqual([[partialTargetId, [partialTargetForward]]]) + }) + forwardListener?.({ targetId, forwards: [liveForward] }) + resolveForwards([]) + resolveDetected([detectedPort]) + + await vi.waitFor(() => { + expect( + setPortForwards.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId) + ).toEqual([[targetId, [liveForward]]]) + expect( + setDetectedPorts.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId) + ).toEqual([[targetId, [detectedPort]]]) + expect(listPortForwards).toHaveBeenCalledTimes(4) + expect(listDetectedPorts).toHaveBeenCalledTimes(4) + }) + forwardListener?.({ targetId, forwards: [liveForward] }) + detectedListener?.({ targetId: secondTargetId, ports: [secondDetectedPort] }) + resolveSecondForwards([secondForward]) + resolveSecondDetected([]) + + await vi.waitFor(() => { + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === secondTargetId + ) + ).toEqual([[secondTargetId, [secondForward]]]) + expect( + setDetectedPorts.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === secondTargetId + ) + ).toEqual([[secondTargetId, [secondDetectedPort]]]) + }) + }) + it('caps pending mobile state events while startup hydration is unresolved', async () => { const setFitOverride = vi.fn() const hydrateOverrides = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 915df3dc05a..ba500554ae2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2631,6 +2631,37 @@ export function useIpcEvents(): void { } const sshStateWatermarkByTargetId = new Map() + const pendingPortHydrationByTargetId = new Map< + string, + { receivedForwardPush: boolean; receivedDetectedPush: boolean } + >() + const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { + const pendingPortHydration = { + receivedForwardPush: false, + receivedDetectedPush: false + } + pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) + const isHydrationAuthorityCurrent = (): boolean => + !directSshEffectStopped && + directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority) + const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { + // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { + useAppStore.getState().setPortForwards(targetId, forwards) + } + }) + const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { + useAppStore.getState().setDetectedPorts(targetId, detected) + } + }) + // Why: one failed or stalled port stream must not block the other stream or later targets. + void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { + if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { + pendingPortHydrationByTargetId.delete(targetId) + } + }) + } let applySshConnectionStateChange!: ( targetId: string, state: SshConnectionState, @@ -2668,23 +2699,6 @@ export function useIpcEvents(): void { state as SshConnectionState, 'initial-hydration' ) - // Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty. - if ((state as SshConnectionState).status === 'connected') { - const authority = currentDirectSshAuthority(target.id) - const [forwards, detected] = await Promise.all([ - window.api.ssh.listPortForwards({ targetId: target.id }), - window.api.ssh.listDetectedPorts({ targetId: target.id }) - ]) - // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - if ( - !directSshEffectStopped && - authority && - directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority) - ) { - useAppStore.getState().setPortForwards(target.id, forwards) - useAppStore.getState().setDetectedPorts(target.id, detected) - } - } } } } catch { @@ -2706,12 +2720,20 @@ export function useIpcEvents(): void { unsubs.push( window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedForwardPush = true + } useAppStore.getState().setPortForwards(targetId, forwards) }) ) unsubs.push( window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedDetectedPush = true + } useAppStore.getState().setDetectedPorts(targetId, ports) }) ) @@ -2736,7 +2758,7 @@ export function useIpcEvents(): void { latest?.targetId !== targetId || !latest?.providerEpoch || latest.connectionGeneration === undefined || - sshStateWatermarkByTargetId.get(targetId) !== watermark + (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark ) { return } @@ -2840,6 +2862,10 @@ export function useIpcEvents(): void { }, { authority, previousAuthority, origin } ) + // Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority. + if (origin === 'initial-hydration') { + hydrateSshPorts(targetId, authority) + } } let sshTargetStateEventId = 0 diff --git a/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts b/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts new file mode 100644 index 00000000000..9d4c35b924b --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts @@ -0,0 +1,225 @@ +import { request } from 'node:http' + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +export type PortForwardEvidence = { + events: { targetId: string; forwards: { localPort: number; remotePort: number }[] }[] + rendererForwards: { localPort: number; remotePort: number }[] + managerForwards: { localPort: number; remotePort: number }[] + persistedForwards: { localPort: number; remotePort: number }[] +} + +export function requestForward(localPort: number): Promise { + return new Promise((resolve, reject) => { + const req = request( + { host: '127.0.0.1', port: localPort, path: '/', method: 'GET', timeout: 2_000 }, + (response) => { + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk) => { + body += chunk + }) + response.on('end', () => resolve(body)) + } + ) + req.once('error', reject) + req.once('timeout', () => req.destroy(new Error('Forwarded HTTP request timed out'))) + req.end() + }) +} + +export function startRemoteHttpListener( + target: DockerSshRelayTarget, + port: number, + marker: string +): number { + const script = [ + "const http = require('node:http')", + `const marker = ${JSON.stringify(marker)}`, + "const server = http.createServer((_request, response) => response.end(marker + '\\n'))", + `server.listen(${port}, '127.0.0.1')` + ].join(';') + execDockerSshRelayTargetCommand( + target, + [ + `nohup node -e ${shellQuote(script)} >/tmp/orca-http-${port}.log 2>&1 < /dev/null &`, + `echo $! >/tmp/orca-http-${port}.pid` + ].join(' ') + ) + return Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`)) +} + +export function readRemoteListenerIdentity( + target: DockerSshRelayTarget, + port: number +): { pid: number; executable: string; command: string } { + const pid = Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`)) + return { + pid, + executable: execDockerSshRelayTargetCommand(target, `readlink /proc/${pid}/exe`), + command: execDockerSshRelayTargetCommand(target, `tr '\\000' ' ' { + await electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + __sshPortForwardOriginalWarn?: typeof console.warn + } + scope.__sshPortForwardWarnings = [] + scope.__sshPortForwardOriginalWarn = console.warn + console.warn = (...args: unknown[]) => { + const message = args.map(String).join(' ') + if (message.includes('[ssh')) { + scope.__sshPortForwardWarnings?.push(message) + } + scope.__sshPortForwardOriginalWarn?.(...args) + } + }) +} + +export async function readLifecycleWarnings(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + } + return scope.__sshPortForwardWarnings ?? [] + }) +} + +export async function restoreLifecycleWarningCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + __sshPortForwardOriginalWarn?: typeof console.warn + } + if (scope.__sshPortForwardOriginalWarn) { + console.warn = scope.__sshPortForwardOriginalWarn + } + delete scope.__sshPortForwardWarnings + delete scope.__sshPortForwardOriginalWarn + }) +} + +export async function installRendererForwardCapture(page: Page): Promise { + await page.evaluate(() => { + const scope = window as typeof window & { + __sshPortForwardEvents?: unknown[] + __sshPortForwardUnsubscribe?: () => void + } + scope.__sshPortForwardEvents = [] + scope.__sshPortForwardUnsubscribe?.() + scope.__sshPortForwardUnsubscribe = window.api.ssh.onPortForwardsChanged((event) => { + scope.__sshPortForwardEvents?.push(event) + }) + }) +} + +export async function readPortForwardEvidence( + page: Page, + targetId: string +): Promise { + return page.evaluate( + async ({ targetId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const target = (await window.api.ssh.listTargets()).find((entry) => entry.id === targetId) + const scope = window as typeof window & { + __sshPortForwardEvents?: PortForwardEvidence['events'] + } + return { + events: scope.__sshPortForwardEvents ?? [], + rendererForwards: store.getState().portForwardsByConnection[targetId] ?? [], + managerForwards: await window.api.ssh.listPortForwards({ targetId }), + persistedForwards: target?.portForwards ?? [] + } + }, + { targetId } + ) +} + +export async function openPortsPanel(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('ports') + state?.setRightSidebarOpen(true) + }) + await expect(page.getByText('Ports', { exact: true }).last()).toBeVisible() +} + +export async function forwardPortFromPanel( + page: Page, + localPort: number, + remotePort: number +): Promise { + await page.getByRole('button', { name: 'Add', exact: true }).last().click() + const dialog = page.getByRole('dialog', { name: 'Forward a Port' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Remote Port').fill(String(remotePort)) + await dialog.getByLabel('Local Port').fill(String(localPort)) + await dialog.getByRole('button', { name: 'Forward', exact: true }).click() + await expect(dialog).not.toBeVisible() +} + +export async function addPortForward( + page: Page, + args: { + targetId: string + localPort: number + remotePort: number + label: string + } +): Promise<{ id: string }> { + return page.evaluate( + ({ targetId, localPort, remotePort, label }) => + window.api.ssh.addPortForward({ + targetId, + localPort, + remoteHost: '127.0.0.1', + remotePort, + label + }), + args + ) +} + +export async function expectForwardEvidence( + page: Page, + targetId: string, + expected: { localPort: number; remotePort: number }[] +): Promise { + await expect + .poll( + async () => { + const evidence = await readPortForwardEvidence(page, targetId) + return { + renderer: evidence.rendererForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })), + manager: evidence.managerForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })), + persisted: evidence.persistedForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })) + } + }, + { timeout: 30_000, message: 'renderer, manager, and persisted forward state did not agree' } + ) + .toEqual({ renderer: expected, manager: expected, persisted: expected }) +} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts new file mode 100644 index 00000000000..7127a754163 --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts @@ -0,0 +1,158 @@ +import { createServer } from 'node:net' + +import type { ElectronApplication } from '@stablyai/playwright-test' + +type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown + +type SnapshotBarrierState = { + targetId: string + captureClaimed: boolean + captured: boolean + released: boolean + release: () => void + originalHandler: InvokeHandler + handlerReturned: Promise + markHandlerReturned: () => void +} + +export type ReservedLocalPort = { + port: number + release: () => Promise +} + +export async function reserveLocalPort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + throw new Error('Unable to reserve a local port') + } + let released = false + return { + port: address.port, + release: async () => { + if (released) { + return + } + released = true + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } +} + +export async function installSshPortForwardSnapshotBarrier( + app: ElectronApplication, + targetId: string +): Promise { + await app.evaluate(({ ipcMain }, targetId) => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + const handlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + const originalHandler = handlers?.get('ssh:listPortForwards') + if (!handlers || !originalHandler) { + throw new Error('ssh:listPortForwards handler is unavailable') + } + if (scope.__sshPortForwardSnapshotBarrier) { + throw new Error('SSH port-forward snapshot barrier is already installed') + } + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + let markHandlerReturned!: () => void + const handlerReturned = new Promise((resolve) => { + markHandlerReturned = resolve + }) + const state: SnapshotBarrierState = { + targetId, + captureClaimed: false, + captured: false, + released: false, + release, + originalHandler, + handlerReturned, + markHandlerReturned + } + scope.__sshPortForwardSnapshotBarrier = state + handlers.set('ssh:listPortForwards', async (event, args) => { + if (state.captureClaimed || args?.targetId !== state.targetId) { + return state.originalHandler(event, args) + } + state.captureClaimed = true + const snapshot = await state.originalHandler(event, args) + state.captured = true + await barrier + state.markHandlerReturned() + return snapshot + }) + }, targetId) +} + +export async function readSshPortForwardSnapshotBarrier( + app: ElectronApplication +): Promise<{ captured: boolean; released: boolean }> { + return app.evaluate(() => { + const state = ( + globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + ).__sshPortForwardSnapshotBarrier + return { + captured: state?.captured ?? false, + released: state?.released ?? false + } + }) +} + +export async function releaseSshPortForwardSnapshotBarrier( + app: ElectronApplication +): Promise { + await app.evaluate(async () => { + const state = ( + globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + ).__sshPortForwardSnapshotBarrier + if (state && !state.released) { + state.released = true + state.release() + } + await state?.handlerReturned + }) +} + +export async function restoreSshPortForwardSnapshotHandler( + app: ElectronApplication +): Promise { + await app.evaluate(({ ipcMain }) => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + const state = scope.__sshPortForwardSnapshotBarrier + if (!state) { + return + } + if (!state.released) { + state.released = true + state.release() + } + const handlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + handlers?.set('ssh:listPortForwards', state.originalHandler) + delete scope.__sshPortForwardSnapshotBarrier + }) +} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts new file mode 100644 index 00000000000..e85564d185e --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts @@ -0,0 +1,69 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { describe, expect, it, vi } from 'vitest' +import { + installSshPortForwardSnapshotBarrier, + readSshPortForwardSnapshotBarrier, + releaseSshPortForwardSnapshotBarrier, + restoreSshPortForwardSnapshotHandler +} from './ssh-port-forward-snapshot-barrier' + +type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown + +describe('SSH port-forward snapshot barrier', () => { + it('holds only the first matching request while its snapshot is unresolved', async () => { + const handlers = new Map() + let resolveFirstSnapshot: (value: string[]) => void = () => {} + const firstSnapshot = new Promise((resolve) => { + resolveFirstSnapshot = resolve + }) + let callCount = 0 + const originalHandler = vi.fn(() => { + callCount += 1 + return callCount === 1 ? firstSnapshot : Promise.resolve(['later-snapshot']) + }) + handlers.set('ssh:listPortForwards', originalHandler) + const app = { + evaluate: ( + callback: (electron: unknown, arg?: unknown) => unknown, + arg?: unknown + ): Promise => + Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg)) + } as unknown as ElectronApplication + + let heldRequestStarted = false + await installSshPortForwardSnapshotBarrier(app, 'target-1') + try { + const wrappedHandler = handlers.get('ssh:listPortForwards') + expect(wrappedHandler).toBeTypeOf('function') + if (!wrappedHandler) { + throw new Error('Wrapped handler unavailable') + } + + heldRequestStarted = true + const firstRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) + await vi.waitFor(() => expect(originalHandler).toHaveBeenCalledOnce()) + const laterRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) + await expect(laterRequest).resolves.toEqual(['later-snapshot']) + expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ + captured: false, + released: false + }) + + resolveFirstSnapshot(['held-snapshot']) + await vi.waitFor(async () => { + expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ + captured: true, + released: false + }) + }) + await releaseSshPortForwardSnapshotBarrier(app) + await expect(firstRequest).resolves.toEqual(['held-snapshot']) + } finally { + resolveFirstSnapshot(['cleanup-snapshot']) + if (heldRequestStarted) { + await releaseSshPortForwardSnapshotBarrier(app) + } + await restoreSshPortForwardSnapshotHandler(app) + } + }) +}) diff --git a/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts b/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts new file mode 100644 index 00000000000..30fcc0e98d3 --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts @@ -0,0 +1,145 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +type CapturedSshState = { + status: string + providerEpoch?: string + connectionGeneration?: number +} + +export async function trustDockerSshHost( + electronApp: ElectronApplication, + target: DockerSshRelayTarget +): Promise { + const sshDir = join(target.tempDir, '.ssh') + mkdirSync(sshDir, { recursive: true }) + const knownHostsPath = join(sshDir, 'known_hosts') + const hostKeys = execFileSync('ssh-keyscan', ['-p', String(target.port), '127.0.0.1'], { + encoding: 'utf8' + }) + writeFileSync(knownHostsPath, hostKeys) + const invocationLogPath = join(target.tempDir, 'system-ssh-invocations') + const wrapperPath = join(target.tempDir, 'verified-system-ssh') + writeFileSync( + wrapperPath, + [ + '#!/bin/sh', + 'kind=transport', + 'for arg in "$@"; do', + ' if [ "$arg" = "-L" ]; then kind=forward; fi', + 'done', + `printf '%s\\n' "$kind" >> ${shellQuote(invocationLogPath)}`, + `exec /usr/bin/ssh -o UserKnownHostsFile=${shellQuote(knownHostsPath)} -o StrictHostKeyChecking=yes "$@"` + ].join('\n') + ) + chmodSync(wrapperPath, 0o755) + await electronApp.evaluate((_electron, path) => { + process.env.ORCA_SYSTEM_SSH_PATH = path + }, wrapperPath) + return invocationLogPath +} + +export function readSystemSshInvocationKinds(invocationLogPath: string): string[] { + if (!existsSync(invocationLogPath)) { + return [] + } + return readFileSync(invocationLogPath, 'utf8').split(/\r?\n/).filter(Boolean) +} + +export function terminateDockerSshRelayConnectChannel(target: DockerSshRelayTarget): number { + const output = execDockerSshRelayTargetCommand( + target, + ` +count=0 +for proc in /proc/[0-9]*; do + [ -r "$proc/cmdline" ] || continue + argv=() + mapfile -d '' -t argv < "$proc/cmdline" 2>/dev/null || continue + [ "\${argv[1]##*/}" = relay.js ] || continue + mode= + for arg in "\${argv[@]:2}"; do + if [ "$arg" = --connect ]; then mode=connect; fi + if [ "$arg" = --detached ]; then mode=detached; fi + done + if [ "$mode" = connect ]; then + kill -TERM "\${proc##*/}" + count=$((count + 1)) + fi +done +printf '%s' "$count" +` + ) + return Number(output) +} + +export async function installSshStateCapture(page: Page, targetId: string): Promise { + await page.evaluate((targetId) => { + const scope = window as typeof window & { + __sshLifecycleStates?: CapturedSshState[] + __sshLifecycleStateUnsubscribe?: () => void + } + scope.__sshLifecycleStateUnsubscribe?.() + scope.__sshLifecycleStates = [] + scope.__sshLifecycleStateUnsubscribe = window.api.ssh.onStateChanged((event) => { + if (event.targetId === targetId) { + scope.__sshLifecycleStates?.push(event.state) + } + }) + }, targetId) +} + +export async function readSshStateCapture(page: Page): Promise { + return page.evaluate( + () => + ( + window as typeof window & { + __sshLifecycleStates?: CapturedSshState[] + } + ).__sshLifecycleStates ?? [] + ) +} + +export async function forceDockerSshRelayChannelReconnect( + page: Page, + target: DockerSshRelayTarget, + targetId: string +): Promise { + await installSshStateCapture(page, targetId) + const authority = await page.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + targetId + ) + expect(authority).toMatchObject({ + status: 'connected', + providerEpoch: expect.any(String), + connectionGeneration: expect.any(Number) + }) + expect(terminateDockerSshRelayConnectChannel(target)).toBeGreaterThan(0) + await expect + .poll( + async () => { + const states = await readSshStateCapture(page) + const current = await page.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + targetId + ) + return ( + states.some((state) => state.status === 'reconnecting') && + states.some((state) => state.status === 'connected') && + current?.status === 'connected' && + (current.providerEpoch !== authority?.providerEpoch || + current.connectionGeneration !== authority?.connectionGeneration) + ) + }, + { timeout: 30_000, message: 'in-place relay channel did not reconnect' } + ) + .toBe(true) +} diff --git a/tests/e2e/ssh-port-forward-lifecycle.spec.ts b/tests/e2e/ssh-port-forward-lifecycle.spec.ts new file mode 100644 index 00000000000..fff647120b8 --- /dev/null +++ b/tests/e2e/ssh-port-forward-lifecycle.spec.ts @@ -0,0 +1,370 @@ +import { createServer } from 'node:net' + +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + connectDockerSshRelayTarget, + reconnectDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + installSshPortForwardSnapshotBarrier, + readSshPortForwardSnapshotBarrier, + releaseSshPortForwardSnapshotBarrier, + reserveLocalPort, + restoreSshPortForwardSnapshotHandler +} from './helpers/ssh-port-forward-snapshot-barrier' +import { + addPortForward, + expectForwardEvidence, + forwardPortFromPanel, + installLifecycleWarningCapture, + installRendererForwardCapture, + openPortsPanel, + readLifecycleWarnings, + readPortForwardEvidence, + readRemoteListenerIdentity, + requestForward, + restoreLifecycleWarningCapture, + startRemoteHttpListener +} from './helpers/ssh-port-forward-lifecycle-evidence' +import { + forceDockerSshRelayChannelReconnect, + readSshStateCapture, + readSystemSshInvocationKinds, + trustDockerSshHost +} from './helpers/ssh-port-forward-transport-evidence' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const FORCE_SYSTEM_SSH = process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' +const REMOTE_PORT = 7860 +const REFRESH_BARRIER_PORT = 7861 +const SCAN_REFRESH_PORT = 7862 + +test.describe('Docker SSH port-forward lifecycle', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH lifecycle uses POSIX process inspection.') + + test('keeps a user-forwarded listener live across scan refresh @headful', async ({ + electronApp, + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const localPortReservation = await reserveLocalPort() + const unrelatedLocalPortReservation = await reserveLocalPort() + const localPort = localPortReservation.port + const unrelatedLocalPort = unrelatedLocalPortReservation.port + const marker = `ORCA_FORWARD_${Date.now()}` + const unrelatedMarker = `${marker}_UNRELATED` + try { + target = startDockerSshRelayTarget(testInfo) + const systemSshInvocationLogPath = await trustDockerSshHost(electronApp, target) + await installLifecycleWarningCapture(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target) + const remotePid = startRemoteHttpListener(target, REMOTE_PORT, marker) + const unrelatedRemotePid = startRemoteHttpListener( + target, + REFRESH_BARRIER_PORT, + unrelatedMarker + ) + await openPortsPanel(orcaPage) + + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.find((entry) => entry.port === port)?.pid ?? null), + { targetId: remote.targetId, port: REMOTE_PORT } + ), + { timeout: 45_000, message: 'remote HTTP listener was not detected' } + ) + .toBe(remotePid) + await expect(orcaPage.getByText(`:${REMOTE_PORT}`, { exact: true })).toBeVisible() + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.some((entry) => entry.port === port)), + { targetId: remote.targetId, port: REFRESH_BARRIER_PORT } + ), + { timeout: 45_000, message: 'scan-refresh barrier listener was not detected' } + ) + .toBe(true) + await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible() + + await installSshPortForwardSnapshotBarrier(electronApp, remote.targetId) + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await orcaPage.reload() + await waitForSessionReady(orcaPage, 60_000) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 60_000 }) + .toBe(remote.worktreeId) + await expect + .poll( + () => + orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status, + remote.targetId + ), + { timeout: 60_000, message: 'renderer did not restore the connected SSH target' } + ) + .toBe('connected') + await expect + .poll(() => readSshPortForwardSnapshotBarrier(electronApp), { + timeout: 30_000, + message: 'renderer hydration did not capture an empty Forwarded snapshot' + }) + .toEqual({ captured: true, released: false }) + + await installRendererForwardCapture(orcaPage) + await openPortsPanel(orcaPage) + await localPortReservation.release() + await forwardPortFromPanel(orcaPage, localPort, REMOTE_PORT) + await expect(orcaPage.getByText('Forwarded', { exact: true })).toBeVisible() + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT } + ]) + if (FORCE_SYSTEM_SSH) { + await expect + .poll(() => readSystemSshInvocationKinds(systemSshInvocationLogPath)) + .toContain('forward') + } else { + expect(readSystemSshInvocationKinds(systemSshInvocationLogPath)).not.toContain('forward') + } + + await releaseSshPortForwardSnapshotBarrier(electronApp) + const postHydrationRoundTripForwards = await orcaPage.evaluate( + (targetId) => window.api.ssh.listPortForwards({ targetId }), + remote.targetId + ) + expect(postHydrationRoundTripForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible() + const staleSnapshotEvidence = await readPortForwardEvidence(orcaPage, remote.targetId) + const staleSnapshotIdentity = readRemoteListenerIdentity(target, REMOTE_PORT) + const staleSnapshotWarnings = await readLifecycleWarnings(electronApp) + expect(staleSnapshotEvidence.managerForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(staleSnapshotEvidence.persistedForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + await expect(requestForward(localPort)).resolves.toContain(marker) + expect(staleSnapshotIdentity).toMatchObject({ + pid: remotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REMOTE_PORT)) + }) + expect(staleSnapshotWarnings.filter((message) => message.includes('Port forward'))).toEqual( + [] + ) + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT } + ]) + await restoreSshPortForwardSnapshotHandler(electronApp) + + startRemoteHttpListener(target, SCAN_REFRESH_PORT, `${marker}_SCAN_REFRESH`) + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.some((entry) => entry.port === port)), + { targetId: remote.targetId, port: SCAN_REFRESH_PORT } + ), + { timeout: 45_000, message: 'scan-refresh listener was not detected in main' } + ) + .toBe(true) + await expect(orcaPage.getByText(`:${SCAN_REFRESH_PORT}`, { exact: true })).toBeVisible() + + await unrelatedLocalPortReservation.release() + const unrelatedForward = await addPortForward(orcaPage, { + targetId: remote.targetId, + localPort: unrelatedLocalPort, + remotePort: REFRESH_BARRIER_PORT, + label: 'unrelated-listener' + }) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + await forceDockerSshRelayChannelReconnect(orcaPage, target, remote.targetId) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + const authorityBeforeTransportReconnect = await orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + remote.targetId + ) + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + await expect + .poll( + async () => { + const state = await orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + remote.targetId + ) + return ( + state?.status === 'connected' && + (state.providerEpoch !== authorityBeforeTransportReconnect?.providerEpoch || + state.connectionGeneration !== + authorityBeforeTransportReconnect?.connectionGeneration) + ) + }, + { timeout: 30_000, message: 'renderer did not observe the reconnected SSH authority' } + ) + .toBe(true) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expect( + orcaPage.getByText(`:${unrelatedLocalPort} → :${REFRESH_BARRIER_PORT}`, { exact: true }) + ).toBeVisible() + + const collisionServer = createServer() + await new Promise((resolve, reject) => { + collisionServer.once('error', reject) + collisionServer.listen(0, '127.0.0.1', resolve) + }) + const collisionAddress = collisionServer.address() + if (!collisionAddress || typeof collisionAddress === 'string') { + throw new Error('Unable to reserve a collision port') + } + try { + const collisionResult = await orcaPage.evaluate( + async ({ targetId, localPort, remotePort }) => { + try { + await window.api.ssh.addPortForward({ + targetId, + localPort, + remoteHost: '127.0.0.1', + remotePort, + label: 'collision' + }) + return { ok: true, message: '' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + }, + { + targetId: remote.targetId, + localPort: collisionAddress.port, + remotePort: REMOTE_PORT + } + ) + expect(collisionResult).toMatchObject({ ok: false }) + expect(collisionResult.message).toMatch(/in use|EADDRINUSE/i) + } finally { + await new Promise((resolve, reject) => + collisionServer.close((error) => (error ? reject(error) : resolve())) + ) + } + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + + const primaryRow = orcaPage + .getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + .locator('../../..') + await primaryRow.getByTitle('Remove').click() + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).not.toBeVisible() + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect(requestForward(localPort)).rejects.toThrow() + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + const evidence = await readPortForwardEvidence(orcaPage, remote.targetId) + const identity = readRemoteListenerIdentity(target, REMOTE_PORT) + const unrelatedIdentity = readRemoteListenerIdentity(target, REFRESH_BARRIER_PORT) + const warnings = await readLifecycleWarnings(electronApp) + const relayReconnectStates = await readSshStateCapture(orcaPage) + testInfo.annotations.push({ + type: 'ssh-port-forward-evidence', + description: JSON.stringify({ + evidence, + identity, + unrelatedIdentity, + removedForwardLocalPort: localPort, + unrelatedForward, + relayReconnectStates, + staleSnapshotEvidence, + systemSshInvocations: readSystemSshInvocationKinds(systemSshInvocationLogPath), + warnings + }) + }) + + expect(identity).toMatchObject({ + pid: remotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REMOTE_PORT)) + }) + expect(unrelatedIdentity).toMatchObject({ + pid: unrelatedRemotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REFRESH_BARRIER_PORT)) + }) + expect(evidence.rendererForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.managerForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.persistedForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.events.at(-1)?.forwards).toContainEqual( + expect.objectContaining({ + localPort: unrelatedLocalPort, + remotePort: REFRESH_BARRIER_PORT + }) + ) + await expect(requestForward(unrelatedLocalPort)).resolves.toContain(unrelatedMarker) + expect(warnings.filter((message) => message.includes('Port forward'))).toEqual([]) + } finally { + await restoreSshPortForwardSnapshotHandler(electronApp).catch(() => undefined) + await restoreLifecycleWarningCapture(electronApp).catch(() => undefined) + await localPortReservation.release().catch(() => undefined) + await unrelatedLocalPortReservation.release().catch(() => undefined) + cleanupDockerSshRelayTarget(target) + } + }) +}) From 336cef3185cf725cc12ab77da9158407ddc7a3de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:39:34 -0700 Subject: [PATCH 057/123] [P1] fix(sidebar): drop onto the indicated board position in virtualized lanes (#11688) The sidebar->board drop translation still read the mounted DOM cards, while getCardDropTarget now indexes the lane's virtual layout, so a searched and scrolled lane resolved the drop to the wrong full-lane position. Co-authored-by: Orca --- .../workspace-kanban-sidebar-drop.test.ts | 153 ++++++++++++++++++ .../sidebar/workspace-kanban-sidebar-drop.ts | 71 ++++---- .../workspace-kanban-virtual-lane-layout.ts | 10 ++ 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 08f627cc0b4..5a4a3d62b4c 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -210,6 +210,91 @@ function appendBoard(): { board: HTMLElement; lane: HTMLElement; firstCard: HTML return { board, lane, firstCard } } +const VIRTUAL_CARD_PITCH = 44 +const VIRTUAL_CARD_HEIGHT = 36 +// Scrolled past the head of the lane, so the mounted window starts mid-list. +const VIRTUAL_SPACER_TOP = -350 + +// A virtualized lane: only `windowRange` of the view is mounted, and the layout +// registration carries the whole view — exactly what the virtualizer publishes. +function appendVirtualBoard(args: { + fullLaneIds: readonly string[] + viewIds: readonly string[] + windowRange: readonly [number, number] + publishFullIds: boolean +}): { lane: HTMLElement; unregister: () => void } { + const board = document.createElement('div') + board.setAttribute('data-workspace-board-selection-surface', '') + setRect(board, { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }) + + const lane = document.createElement('section') + lane.setAttribute('data-workspace-status-drop-target', '') + lane.dataset.workspaceStatus = 'doing' + setRect(lane, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 }) + if (args.publishFullIds) { + lane.dataset.workspaceLaneFullIds = serializeWorkspaceLaneFullIds([...args.fullLaneIds]) ?? '' + } + + const laneScroll = document.createElement('div') + laneScroll.setAttribute('data-workspace-board-lane-scroll', '') + setRect(laneScroll, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 }) + + const totalSize = args.viewIds.length * VIRTUAL_CARD_PITCH + const spacer = document.createElement('div') + setRect(spacer, { + left: 8, + top: VIRTUAL_SPACER_TOP, + right: 192, + bottom: VIRTUAL_SPACER_TOP + totalSize, + width: 184, + height: totalSize + }) + + for (let index = args.windowRange[0]; index <= args.windowRange[1]; index++) { + const worktreeId = args.viewIds[index]! + const card = document.createElement('div') + card.setAttribute('data-workspace-board-card-id', worktreeId) + card.dataset.workspaceBoardCardId = worktreeId + card.dataset.workspaceBoardCardIndex = String(index) + const top = VIRTUAL_SPACER_TOP + index * VIRTUAL_CARD_PITCH + setRect(card, { + left: 8, + top, + right: 192, + bottom: top + VIRTUAL_CARD_HEIGHT, + width: 184, + height: VIRTUAL_CARD_HEIGHT + }) + setVisible(card) + spacer.append(card) + } + + laneScroll.append(spacer) + lane.append(laneScroll) + board.append(lane) + document.body.append(board) + setElementFromPoint(lane) + + const unregister = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement: laneScroll, + spacerElement: spacer, + getItemIds: () => args.viewIds, + getMeasurements: () => + args.viewIds.map((_, index) => ({ + index, + start: index * VIRTUAL_CARD_PITCH, + end: index * VIRTUAL_CARD_PITCH + VIRTUAL_CARD_HEIGHT + })) + }) + return { lane, unregister } +} + +// 40-member lane under a search that matches every other card. +function searchedVirtualLaneIds(): { fullLaneIds: string[]; viewIds: string[] } { + const fullLaneIds = Array.from({ length: 40 }, (_, index) => `doing-${index}`) + return { fullLaneIds, viewIds: fullLaneIds.filter((_, index) => index % 2 === 0) } +} + function worktree(args: { id: string workspaceStatus: string @@ -356,6 +441,74 @@ describe('workspace kanban sidebar drop DOM bridge', () => { expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 2)).toBe(4) }) + it('lands a virtualized searched lane drop where the indicator pointed', () => { + const { fullLaneIds, viewIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds, + windowRange: [8, 17], + publishFullIds: true + }) + + const target = getWorkspaceKanbanSidebarDropTarget(24, 210) + // The indicator sits between the mounted cards for view items 12 and 13, + // whose full-lane neighbours are doing-24 and doing-26. + expect(target).toMatchObject({ status: 'doing', dropIndex: 13, dropIndicatorY: 218 }) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', target.dropIndex)).toBe(26) + + unregister() + }) + + it('translates virtualized searched lane drops above and below the mounted window', () => { + const { fullLaneIds, viewIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds, + windowRange: [8, 17], + publishFullIds: true + }) + + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 0)).toBe(0) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 3)).toBe(6) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 19)).toBe(38) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 20)).toBe(39) + + unregister() + }) + + it('passes a virtualized unfiltered lane drop index through untranslated', () => { + const { fullLaneIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds: fullLaneIds, + windowRange: [8, 17], + publishFullIds: false + }) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: fullLaneIds } + ]) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 13)).toBe(13) + + unregister() + }) + + it('still counts unlaid-out cards as lane members without a virtual layout', () => { + const { lane } = appendBoard() + const secondCard = lane.querySelectorAll('[data-workspace-board-card-id]')[1]! + secondCard.remove() + const hiddenCard = document.createElement('div') + hiddenCard.setAttribute('data-workspace-board-card-id', 'doing-hidden') + hiddenCard.dataset.workspaceBoardCardId = 'doing-hidden' + lane.append(hiddenCard, secondCard) + setElementFromPoint(lane) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-hidden', 'doing-b'] } + ]) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 1)).toBe(2) + }) + it('passes the drop index through for a lane it cannot find', () => { appendBoard() diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index 363038bd529..8475d91cdd8 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -23,9 +23,11 @@ import { updateCardDropIndicator, type WorkspaceKanbanCardDropTarget } from './workspace-kanban-card-pointer-drag-dom' +import { getWorkspaceKanbanVirtualLaneItemIds } from './workspace-kanban-virtual-lane-layout' const BOARD_SELECTOR = '[data-workspace-board-selection-surface]' const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' +const LANE_SCROLL_SELECTOR = '[data-workspace-board-lane-scroll]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null @@ -53,24 +55,33 @@ function getLaneCardIds(lane: HTMLElement): HTMLElement[] { return Array.from(lane.querySelectorAll(CARD_SELECTOR)) } -// Mirrors getCardDropTarget's card scan so both sides share one index space. -// The offsetParent read forces layout, so callers pass the card list they -// already collected rather than re-querying. -function toRenderedCardIds(cards: readonly HTMLElement[]): string[] { - return cards - .filter((card) => card.offsetParent !== null) - .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) -} - -// Why: board search hides non-matching cards, so the rendered card scan is a -// filtered lane. Lanes publish their full membership for exactly this reader. -// The fallback is the unfiltered card list — a lane member the browser is not -// laying out is still a member for manual-order purposes. -function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] { - return ( - parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? - cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []) - ) +/** + * `viewIds` is the lane in the index space `getCardDropTarget` reports, and + * `fullLaneIds` is the lane's whole membership. Board search hides non-matching + * cards, so lanes publish their full membership for exactly this reader. + * + * Why the virtual layout first: lanes virtualize, so the mounted cards are a + * window rather than the lane view, and `getCardDropTarget` indexes the same + * virtual layout. The DOM scan is only the fallback for a lane the virtualizer + * never claimed — there the unfiltered card list is the full lane, since a card + * the browser is not laying out is still a member for manual-order purposes. + */ +function toLaneDropIds(lane: HTMLElement): { fullLaneIds: string[]; viewIds: string[] } { + const publishedFullLaneIds = parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) + const laneScroll = lane.querySelector(LANE_SCROLL_SELECTOR) + const virtualItemIds = laneScroll ? getWorkspaceKanbanVirtualLaneItemIds(laneScroll) : null + if (virtualItemIds) { + const viewIds = [...virtualItemIds] + return { fullLaneIds: publishedFullLaneIds ?? viewIds, viewIds } + } + const cards = getLaneCardIds(lane) + return { + fullLaneIds: + publishedFullLaneIds ?? cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []), + viewIds: cards + .filter((card) => card.offsetParent !== null) + .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) + } } function getStatusDropTargetElement( @@ -128,7 +139,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { if (!status) { return [] } - return [{ key: status, worktreeIds: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }] + return [{ key: status, worktreeIds: toLaneDropIds(lane).fullLaneIds }] }) } @@ -144,26 +155,26 @@ export function getWorkspaceKanbanSidebarDropTarget( } /** - * Translates a tracked drop index — which counts *rendered* cards, matching the - * drop indicator — onto the full lane that `getWorkspaceKanbanSidebarDropGroups` - * reports. Call this once, at the commit boundary: a tracked target can be - * committed after the pointer has left the lane, so translating earlier would - * miss that path. + * Translates a tracked drop index — which counts the lane's *view* items, + * matching the drop indicator — onto the full lane that + * `getWorkspaceKanbanSidebarDropGroups` reports. Call this once, at the commit + * boundary: a tracked target can be committed after the pointer has left the + * lane, so translating earlier would miss that path. */ export function resolveWorkspaceKanbanSidebarFullLaneDropIndex( status: WorkspaceStatus, - renderedDropIndex: number + viewDropIndex: number ): number { const board = getWorkspaceKanbanBoardElement() const lane = board ? getStatusDropTargetElement(board, status) : null if (!lane) { - return renderedDropIndex + return viewDropIndex } - const cards = getLaneCardIds(lane) + const { fullLaneIds, viewIds } = toLaneDropIds(lane) return resolveFullLaneDropIndex({ - fullLaneIds: toLaneFullWorktreeIds(lane, cards), - renderedIds: toRenderedCardIds(cards), - filteredDropIndex: renderedDropIndex + fullLaneIds, + renderedIds: viewIds, + filteredDropIndex: viewDropIndex }) } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts index e142b3617d6..4d7b2897fcd 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts @@ -44,6 +44,16 @@ export function registerWorkspaceKanbanVirtualLaneLayout(args: { } } +/** + * The lane's item ids in the index space `resolveWorkspaceKanbanVirtualLaneDropIndex` + * reports — every item in the lane view, not just the mounted virtual window. + */ +export function getWorkspaceKanbanVirtualLaneItemIds( + scrollElement: HTMLElement +): readonly string[] | null { + return virtualLaneLayouts.get(scrollElement)?.getItemIds() ?? null +} + export function getWorkspaceKanbanVirtualLaneItemRects( scrollElement: HTMLElement ): WorkspaceKanbanVirtualLaneItemRect[] | null { From 451baa1bc47f2ae4fd3cd2654e4199398612a151 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:39:44 -0700 Subject: [PATCH 058/123] fix(mobile): clear state after closing final tab (#11637) * fix(mobile): clear state after closing final tab * fix(mobile): clear terminal on empty snapshots * fix(mobile): preserve terminal during transient empty snapshot --- .../app/h/[hostId]/session/[worktreeId].tsx | 8 +++-- .../mobile-session-last-tab-close.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 mobile/src/session/mobile-session-last-tab-close.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 6a7bfc3a54f..4ec0e564a79 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1894,6 +1894,7 @@ export default function SessionScreen() { setActiveHandle(active.terminal) subscribeToTerminal(active.terminal) } else if (active) { + // Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success. const previous = activeHandleRef.current if (previous) { unsubscribeTerminal(previous) @@ -4133,6 +4134,7 @@ export default function SessionScreen() { reason: 'user' }) if (response.ok) { + const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { pendingBrowserFocusPageIdRef.current = null } @@ -4143,14 +4145,16 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + sessionTabsRef.current = remainingTabs + setSessionTabs(remainingTabs) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) // Why: bulk close re-activates the anchor before awaiting each close; // the render-synced ref sees that switch while this closure would not, // so comparing against the ref keeps the anchor from being nulled out. - if (activeSessionTabIdRef.current === tab.id) { + if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) { activeSessionTabTypeRef.current = null + activeSessionTabIdRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null setActiveHandle(null) diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts new file mode 100644 index 00000000000..7bd6ad179fc --- /dev/null +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +describe('mobile session last-tab close', () => { + it('preserves terminal identity while an empty snapshot may be transient', () => { + const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') + const end = sessionRouteSource.indexOf('const readMarkdownTab', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain('} else if (active) {') + }) + + it('clears stale active identity when closing leaves no tabs', () => { + const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') + const end = sessionRouteSource.indexOf('const bulkCloseActions', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain( + 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' + ) + expect(block).toContain('activeSessionTabIdRef.current = null') + expect(block).toContain('activeHandleRef.current = null') + }) +}) From 4f00b21186daabb219a05cfe16c9e0b9ba8a96e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:40:11 -0700 Subject: [PATCH 059/123] fix(mobile): restore Codex chat session identity (#11636) * fix(mobile): restore Codex chat session identity * fix(mobile): reconcile native chat session ownership --- src/main/runtime/orca-runtime.test.ts | 122 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 42 ++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6c137a4dcf0..153f7840760 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -22157,6 +22157,128 @@ describe('OrcaRuntimeService', () => { ]) }) + it.each([ + { + behavior: 'fills a missing renderer session', + hookAgentType: 'codex', + hookOffset: 0, + rendererSessionId: null, + expectedSessionId: 'hook-session' + }, + { + behavior: 'replaces a stale renderer session at the same event timestamp', + hookAgentType: 'codex', + hookOffset: 0, + rendererSessionId: 'stale-renderer-session', + expectedSessionId: 'hook-session' + }, + { + behavior: 'preserves a renderer session newer than the hook row', + hookAgentType: 'codex', + hookOffset: -1, + rendererSessionId: 'newer-renderer-session', + expectedSessionId: 'newer-renderer-session' + }, + { + behavior: 'rejects a hook session owned by another agent', + hookAgentType: 'claude', + hookOffset: 1, + rendererSessionId: null, + expectedSessionId: null + } + ] as const)( + '$behavior', + async ({ hookAgentType, hookOffset, rendererSessionId, expectedSessionId }) => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `codex-tab:${leafId}` + const providerSession = { + key: 'session_id' as const, + id: 'hook-session' + } + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: hookAgentType, + connectionId: null, + receivedAt: now + hookOffset, + stateStartedAt: now + hookOffset, + tabId: 'codex-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `codex-tab::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `codex-tab::${leafId}`, + parentTabId: 'codex-tab', + leafId, + title: 'Codex', + launchAgent: 'codex', + agentStatus: { + state: 'working', + prompt: 'Reply with MOBILE QA OK and nothing else.', + updatedAt: now, + stateStartedAt: now, + agentType: 'codex', + paneKey, + stateHistory: [], + ...(rendererSessionId + ? { + providerSession: { + key: 'session_id' as const, + id: rendererSessionId + } + } + : {}) + }, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ + state: 'working', + prompt: 'Reply with MOBILE QA OK and nothing else.', + agentType: 'codex' + }) + }) + ) + if (expectedSessionId) { + expect(result.tabs[0]).toHaveProperty('agentStatus.providerSession', { + key: 'session_id', + id: expectedSessionId + }) + } else { + expect(result.tabs[0]).not.toHaveProperty('agentStatus.providerSession') + } + } + ) + it('preserves authoritative OMP identity for Pi-compatible remote terminal snapshots', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d6bc500632f..abc82437657 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -69,7 +69,8 @@ import { import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, - normalizeCompatibleAgentTitleForOwner + normalizeCompatibleAgentTitleForOwner, + resolveCompatibleAgentTypeForOwner } from '../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' import { @@ -28674,6 +28675,9 @@ export class OrcaRuntimeService { const retainedAgentStatus = tab.agentStatus ? null : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) + const hookAgentStatus = tab.agentStatus + ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + : null const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -28691,7 +28695,11 @@ export class OrcaRuntimeService { const ownerAgent = resolvePaneAgentOwner({ launchAgent, - hookAgent: tab.agentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null + hookAgent: + tab.agentStatus?.agentType ?? + hookAgentStatus?.agentType ?? + retainedAgentStatus?.payload.agentType ?? + null }) ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? @@ -28702,8 +28710,32 @@ export class OrcaRuntimeService { ) const liveTitleEvidence = leafTitle ?? ptyTitle const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) + // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. + const rendererStatusAgent = + resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ?? + ownerAgent ?? + undefined + const hookSessionAgent = resolveCompatibleAgentTypeForOwner( + hookAgentStatus?.providerSessionAgentType, + ownerAgent + ) + const hookSessionMatchesRenderer = + !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent + const hookProviderSession = + hookAgentStatus?.providerSession && + hookSessionMatchesRenderer && + (!tab.agentStatus?.providerSession || + (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) + ? hookAgentStatus.providerSession + : tab.agentStatus?.providerSession const normalizedTabAgentStatus = tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, ownerAgent) + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent + ) : null // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. const hasLiveAgentSignal = @@ -28942,6 +28974,8 @@ export class OrcaRuntimeService { * read would keep offering native chat for what is now a plain shell. */ private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { providerSession: AgentProviderSessionMetadata | null + providerSessionAgentType: string | null + providerSessionReceivedAt: number | null agentType: string | null } { let session: AgentStatusIpcPayload | null = null @@ -28967,6 +29001,8 @@ export class OrcaRuntimeService { } return { providerSession: session?.providerSession ?? null, + providerSessionAgentType: session?.agentType ?? null, + providerSessionReceivedAt: session?.receivedAt ?? null, agentType: agent?.agentType ?? null } } From 48e52540d1a8ac720611ec04fd566bb03e515e09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:51:39 -0700 Subject: [PATCH 060/123] fix(macos): verify Full Disk Access and re-arm notices (#11716) * fix(macos): verify full disk access status * perf(macos): keep full disk probe off main thread --- src/main/ipc/developer-permissions.test.ts | 23 +++++++ src/main/ipc/developer-permissions.ts | 21 +------ .../macos-full-disk-access-status.test.ts | 63 +++++++++++++++++++ src/main/macos-full-disk-access-status.ts | 44 +++++++++++++ src/main/macos-tcc-prompt-notice.test.ts | 38 +++++++++-- src/main/macos-tcc-prompt-notice.ts | 26 ++++++-- 6 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 src/main/macos-full-disk-access-status.test.ts create mode 100644 src/main/macos-full-disk-access-status.ts diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index ccb119d0e5c..78c8d6e0be0 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -6,6 +6,7 @@ const { askForMediaAccessMock, getMediaAccessStatusMock, isTrustedAccessibilityClientMock, + getMacosFullDiskAccessStatusMock, execFileMock, createSocketMock, socketMock, @@ -28,6 +29,7 @@ const { askForMediaAccessMock: vi.fn(), getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), + getMacosFullDiskAccessStatusMock: vi.fn(), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('../macos-full-disk-access-status', () => ({ + getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock +})) + import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() isTrustedAccessibilityClientMock.mockReset() + getMacosFullDiskAccessStatusMock.mockReset() + getMacosFullDiskAccessStatusMock.mockResolvedValue('denied') execFileMock.mockReset() execFileMock.mockImplementation((...args: unknown[]) => { const callback = args.at(-1) @@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + it('returns the Full Disk Access read-probe status', async () => { + getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') + registerDeveloperPermissionHandlers() + + const call = handleMock.mock.calls.find( + (registration: unknown[]) => registration[0] === 'developerPermissions:getStatus' + ) + const handler = call?.[1] as (() => Promise) | undefined + + await expect(handler?.()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'granted' + }) + }) + it('clears the local-network prompt fallback timer when UDP send settles first', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index d0d079bd093..dc1171b8c7d 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -1,9 +1,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' -import { access } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' import { ipcMain, shell, systemPreferences } from 'electron' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope } } -async function getFullDiskAccessStatus(): Promise { - const unsupported = unsupportedOffMac() - if (unsupported) { - return unsupported - } - try { - // Why: Safari bookmarks are TCC-protected, so read access is a practical - // Full Disk Access signal without touching user project contents. - await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) - return 'granted' - } catch { - return 'unknown' - } -} - function getAccessibilityStatus(): DeveloperPermissionStatus { const unsupported = unsupportedOffMac() if (unsupported) { @@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('probeMacosFullDiskAccess', () => { + it('reports granted only when the TCC database opens for reading', async () => { + const readProbe = vi.fn().mockResolvedValue(undefined) + + await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') + expect(readProbe).toHaveBeenCalledWith(databasePath) + }) + + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('denied') + }) + + it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('unknown') + }) +}) + +describe('getMacosFullDiskAccessStatus', () => { + it('is unsupported off macOS', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported') + }) +}) diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts new file mode 100644 index 00000000000..aea18b95677 --- /dev/null +++ b/src/main/macos-full-disk-access-status.ts @@ -0,0 +1,44 @@ +import { open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types' + +type ReadProbe = (filePath: string) => Promise + +async function openForRead(filePath: string): Promise { + const handle = await open(filePath, 'r') + await handle.close() +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined +} + +export async function probeMacosFullDiskAccess({ + homeDirectory = homedir(), + readProbe = openForRead +}: { + homeDirectory?: string + readProbe?: ReadProbe +} = {}): Promise { + const databasePath = join( + homeDirectory, + 'Library', + 'Application Support', + 'com.apple.TCC', + 'TCC.db' + ) + try { + await readProbe(databasePath) + return 'granted' + } catch (error) { + const code = errorCode(error) + return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown' + } +} + +export async function getMacosFullDiskAccessStatus(): Promise { + return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported' +} diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 1c268a7e240..e8745b67c61 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({ const { TCC_PROMPT_NOTICE_THRESHOLD, + TCC_PROMPT_NOTICE_VERSION, TCC_PROMPT_WATCH_START_FALLBACK_MS, acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => { handleTccPromptForTests() expect(writeFileAtomically).toHaveBeenCalledTimes(1) const [, contents] = writeFileAtomically.mock.calls[0] as [string, string] - expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false }) + expect(JSON.parse(contents)).toMatchObject({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: 1, + notified: false + }) }) it('never fires again once dismissed, even past the threshold', () => { @@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => { }) it.each([ - { promptCount: 2, notified: false }, - { promptCount: 1, notified: true } - ])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => { + { promptCount: 2, notified: false, acknowledgedAfterClose: false }, + { promptCount: 1, notified: true, acknowledgedAfterClose: true } + ])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false })) @@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => { } }) + it('preserves a legacy permanent opt-out', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ + promptCount: 1, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + }) + ) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toBeNull() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + it('replays an unclosed notice from the new delivery contract', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: false, dismissed: false, @@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: true, dismissed: false, diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index 9d7dea359d0..c51367d8c6a 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' /** Why: the first detected dialog identifies an affected user; the notice remains one-time. */ export const TCC_PROMPT_NOTICE_THRESHOLD = 1 +/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */ +export const TCC_PROMPT_NOTICE_VERSION = 2 + /** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */ export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000 @@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & { } type TccPromptTally = { + noticeVersion: number promptCount: number notified: boolean dismissed: boolean @@ -35,6 +39,7 @@ type TccPromptTally = { } const EMPTY_TALLY: TccPromptTally = { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 0, notified: false, dismissed: false, @@ -57,15 +62,28 @@ function loadTally(): TccPromptTally { try { const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial const dismissed = parsed.dismissed === true - if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') { - // Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing. + if (dismissed) { + return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + } + } + if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) { + return { ...EMPTY_TALLY } + } + if (typeof parsed.acknowledgedAfterClose !== 'boolean') { + // Why: an incomplete tally proves only a past prompt, not that access is still missing. return { ...EMPTY_TALLY } } const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, - notified: parsed.notified === true && (dismissed || acknowledgedAfterClose), - dismissed, + notified: parsed.notified === true && acknowledgedAfterClose, + dismissed: false, acknowledgedAfterClose } } catch { From f23b3308a5eb23855b4d7f4e676e1405568b4a99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:54:40 -0700 Subject: [PATCH 061/123] fix(mobile): route external mouse click and drag to the terminal (#11473) * fix(mobile): route external mouse click and drag to the terminal The terminal WebView suppresses mousedown/click at capture so xterm's own mouse handling stays inert (its onData bytes are dropped by the mobile bridge). That left hardware mouse clicks and drags with no path at all: touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth mouse or trackpad click did nothing (#8818; wheel half landed in #11247). Add a pointer-event router on the terminal surface (pointerType 'mouse', left button only) that mirrors touch semantics: - plain click: same pipeline as a touch tap (links/file paths first, then tracking-mode press+release reports, else keyboard focus), and a click on an active selection dismisses it like touch does - drag with mouse tracking: press at the anchor, per-cell motion reports (drag/any modes), release on pointerup or pointercancel - drag without tracking: character-anchored selection reusing the touch handle-drag plumbing (edge scroll, handles, copy pill) Widen the RN gesture-input grammar to pass left-drag motion reports (SGR button 32, default-encoding byte 64) through the existing validation and rate limiting. Mock server: echo the subscribe viewport and serialize scrollback so the session screen leaves the resubscribe loop, serve the session-tabs subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a [SEND] byte log - the rig used to reproduce and verify this fix on an Android emulator. Fixes #8818 * fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown A drag leaving the terminal surface dropped pointermove/pointerup without pointer capture, stranding the gesture; a pointerup lost outside the WebView could leave a tracked press latched until the next gesture. * fix(mobile): end mouse gestures whose pointerup never reached the surface Capture the mouse pointer on pointerdown so a drag that leaves the surface keeps delivering pointermove/pointerup; when capture is unavailable and the release is lost anyway, synthesize the release from the next buttons==0 pointermove or the next pointerdown, so a tracking TUI is never left with the left button latched down. * fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe * fix(mobile): synthesize the lost-pointerup release at the pointer's current cell * test(mobile): split terminal mouse click and drag coverage * test(mobile): satisfy changed-line quality checks * fix(mobile): cancel stale mock terminal callbacks * refactor(mobile): extract mouse report cell mapping --- mobile/scripts/mock-server-rpc-handlers.ts | 60 +---- .../mock-server-session-tabs-fixture.ts | 24 +- mobile/scripts/mock-server-terminal-stream.ts | 132 ++++++++++ ...erver-terminal-stream-cancellation.test.ts | 74 ++++++ .../terminal/terminal-gesture-input.test.ts | 16 ++ mobile/src/terminal/terminal-gesture-input.ts | 6 +- mobile/src/terminal/terminal-webview-html.ts | 33 +-- ...minal-webview-mouse-click-drag-injected.ts | 199 ++++++++++++++ .../terminal-webview-mouse-click.test.ts | 90 +++++++ .../terminal-webview-mouse-drag.test.ts | 87 ++++++ ...inal-webview-mouse-report-cell-injected.ts | 29 ++ .../terminal-webview-mouse-test-harness.ts | 248 ++++++++++++++++++ 12 files changed, 919 insertions(+), 79 deletions(-) create mode 100644 mobile/scripts/mock-server-terminal-stream.ts create mode 100644 mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-drag.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-test-harness.ts diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index c5aebf054b4..5971b9dee94 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture' -import { - createMockTerminals, - FAKE_SCROLLBACK, - STREAMING_CHUNKS -} from './mock-server-terminal-fixtures' +import { handleMockTerminalRequest } from './mock-server-terminal-stream' import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario' const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2) @@ -64,6 +60,8 @@ export type RpcResponse = { _meta: { runtimeId: string } } +export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void + export const mockScenarioSummary = { repoCount: FAKE_REPOS.length, worktreeCount: fakeWorktrees.length, @@ -111,13 +109,18 @@ export function handleRequest( send: (response: RpcResponse) => void, ws: WebSocket ): void { - const respond = (response: RpcResponse) => { + const respond: RpcRespond = (response, shouldSend) => { + const deliver = () => { + if (shouldSend?.() !== false) { + send(response) + } + } const delay = responseDelayFor(request.method) if (delay > 0) { - setTimeout(() => send(response), delay) + setTimeout(deliver, delay) return } - send(response) + deliver() } // Each returns false for methods it does not own; first owner wins. @@ -126,7 +129,8 @@ export function handleRequest( handleMockFilePreviewRequest(request, respond, success, error) || handleMockAccountRequest(request, respond, success, error) || handleMockNativeChatRequest(request, respond, success, error, ws) || - handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) + handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) || + handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId) ) { return } @@ -279,44 +283,6 @@ export function handleRequest( break } - case 'terminal.list': { - const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree)) - respond( - success(request.id, { - terminals, - totalCount: terminals.length, - truncated: false - }) - ) - break - } - - case 'terminal.subscribe': { - respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) - - let chunkIndex = 0 - const interval = setInterval(() => { - if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { - clearInterval(interval) - if (ws.readyState === ws.OPEN) { - respond(success(request.id, { type: 'end' })) - } - return - } - respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) - chunkIndex++ - }, 500) - break - } - - case 'terminal.send': - respond(success(request.id, { send: { handle: 'term-1', ok: true } })) - break - - case 'terminal.unsubscribe': - respond(success(request.id, { unsubscribed: true })) - break - case 'files.open': case 'files.openDiff': respond( diff --git a/mobile/scripts/mock-server-session-tabs-fixture.ts b/mobile/scripts/mock-server-session-tabs-fixture.ts index fbc94c2829e..f4a5bd1da1c 100644 --- a/mobile/scripts/mock-server-session-tabs-fixture.ts +++ b/mobile/scripts/mock-server-session-tabs-fixture.ts @@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest( // absent or `id:`-prefixed selector means. resolveWorktreeId: (selector: unknown) => string | undefined ): boolean { - if (request.method !== 'session.tabs.list') { - return false + if (request.method === 'session.tabs.list') { + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + respond(success(request.id, createMockSessionTabs(worktreeId))) + return true } - const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' - respond(success(request.id, createMockSessionTabs(worktreeId))) - return true + if (request.method === 'session.tabs.subscribe') { + // Without a live stream the client's health loop keeps invalidating list + // fetches mid-flight (barrier bump on the failed probe), so the session + // screen never leaves 'Loading tabs'. snapshot then updated => 'live'. + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + const snapshot = createMockSessionTabs(worktreeId) + respond(success(request.id, { type: 'snapshot', ...snapshot }, true)) + respond(success(request.id, { type: 'updated', ...snapshot }, true)) + return true + } + if (request.method === 'session.tabs.unsubscribe') { + respond(success(request.id, { unsubscribed: true })) + return true + } + return false } diff --git a/mobile/scripts/mock-server-terminal-stream.ts b/mobile/scripts/mock-server-terminal-stream.ts new file mode 100644 index 00000000000..f747ec59b21 --- /dev/null +++ b/mobile/scripts/mock-server-terminal-stream.ts @@ -0,0 +1,132 @@ +import type { WebSocket } from 'ws' +import type { RpcRequest, RpcRespond, RpcResponse } from './mock-server-rpc-handlers' +import { + createMockTerminals, + FAKE_SCROLLBACK, + STREAMING_CHUNKS +} from './mock-server-terminal-fixtures' + +// Why: the client resubscribes on every viewport change; without cancellation +// each resubscribe would stack another interval streaming under a dead request. +type TerminalStream = { interval: ReturnType | null } +const terminalStreams = new WeakMap>() + +function clearTerminalStream(ws: WebSocket, terminal: string): void { + const perTerminal = terminalStreams.get(ws) + const stream = perTerminal?.get(terminal) + if (stream) { + stopTerminalStreamInterval(stream) + perTerminal?.delete(terminal) + } +} + +function beginTerminalStream(ws: WebSocket, terminal: string): TerminalStream { + clearTerminalStream(ws, terminal) + let perTerminal = terminalStreams.get(ws) + if (!perTerminal) { + perTerminal = new Map() + terminalStreams.set(ws, perTerminal) + } + const stream = { interval: null } + perTerminal.set(terminal, stream) + return stream +} + +function isCurrentTerminalStream(ws: WebSocket, terminal: string, stream: TerminalStream): boolean { + return terminalStreams.get(ws)?.get(terminal) === stream && ws.readyState === ws.OPEN +} + +function stopTerminalStreamInterval(stream: TerminalStream): void { + if (stream.interval !== null) { + clearInterval(stream.interval) + stream.interval = null + } +} + +/** Terminal list/stream/input backend for the mock server. Returns false for + * methods it does not own. */ +export function handleMockTerminalRequest( + request: RpcRequest, + respond: RpcRespond, + success: (id: string, result: unknown, streaming?: boolean) => RpcResponse, + ws: WebSocket, + // Shared with `session.tabs.list` so both surfaces agree on which worktree an + // absent or `id:`-prefixed selector means. + resolveWorktreeId: (selector: unknown) => string | undefined +): boolean { + switch (request.method) { + case 'terminal.list': { + const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree)) + respond( + success(request.id, { + terminals, + totalCount: terminals.length, + truncated: false + }) + ) + return true + } + + case 'terminal.subscribe': { + const terminal = String(request.params?.terminal ?? 'term-1') + const stream = beginTerminalStream(ws, terminal) + const isCurrent = () => isCurrentTerminalStream(ws, terminal, stream) + // Why: the client resubscribes until scrollback echoes its viewport dims; + // the legacy `lines` shape left the session screen in that loop forever. + const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined + // MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback + // itself so every xterm re-init re-enters the mode - used by mouse/touch + // input repros (#8818). + const tuiPreamble = + process.env.MOCK_TUI === '1' + ? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n' + : '' + respond( + success(request.id, { + type: 'scrollback', + cols: viewport?.cols ?? 80, + rows: viewport?.rows ?? 24, + serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble, + truncated: false + }), + isCurrent + ) + + let chunkIndex = 0 + stream.interval = setInterval(() => { + if (!isCurrent()) { + stopTerminalStreamInterval(stream) + return + } + if (chunkIndex >= STREAMING_CHUNKS.length) { + // Why: no `end` event - a live terminal stream stays open, and `end` + // makes the client tear the subscription down and blank the pane. + stopTerminalStreamInterval(stream) + return + } + respond( + success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true), + isCurrent + ) + chunkIndex++ + }, 500) + return true + } + + case 'terminal.send': + // Input-routing repros (#8818) assert on the exact bytes reaching the host. + console.log( + `[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}` + ) + respond(success(request.id, { send: { handle: 'term-1', ok: true } })) + return true + + case 'terminal.unsubscribe': + clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1')) + respond(success(request.id, { unsubscribed: true })) + return true + + default: + return false + } +} diff --git a/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts new file mode 100644 index 00000000000..66e4900fe7c --- /dev/null +++ b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { handleMockTerminalRequest } from '../../scripts/mock-server-terminal-stream' +import { + handleRequest, + type RpcRequest, + type RpcRespond, + type RpcResponse +} from '../../scripts/mock-server-rpc-handlers' + +type DeferredResponse = { + response: RpcResponse + shouldSend?: () => boolean +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + return { + id, + ok: true, + result, + ...(streaming ? { streaming: true as const } : {}), + _meta: { runtimeId: 'test' } + } +} + +function request(id: string, method: string): RpcRequest { + return { id, method, params: { terminal: 'term-1', viewport: { cols: 80, rows: 24 } } } +} + +describe('mock terminal stream cancellation', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + }) + + it('invalidates delayed frames after resubscribe and unsubscribe', () => { + vi.useFakeTimers() + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const deferred: DeferredResponse[] = [] + const respond: RpcRespond = (response, shouldSend) => { + deferred.push({ response, shouldSend }) + } + const handle = (rpcRequest: RpcRequest) => + handleMockTerminalRequest(rpcRequest, respond, success, ws, () => 'wt') + + expect(handle(request('old', 'terminal.subscribe'))).toBe(true) + vi.advanceTimersByTime(500) + expect(deferred.filter(({ response }) => response.id === 'old')).toHaveLength(2) + + expect(handle(request('new', 'terminal.subscribe'))).toBe(true) + const oldResponses = deferred.filter(({ response }) => response.id === 'old') + const newResponses = deferred.filter(({ response }) => response.id === 'new') + expect(oldResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === true)).toBe(true) + + expect(handle(request('stop', 'terminal.unsubscribe'))).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + }) + + it('drops queued delayed frames after stream cancellation', () => { + vi.useFakeTimers() + vi.stubEnv('MOCK_RPC_DELAY_TERMINAL_SUBSCRIBE_MS', '1000') + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const send = vi.fn<(response: RpcResponse) => void>() + + handleRequest(request('old', 'terminal.subscribe'), send, ws) + vi.advanceTimersByTime(500) + handleRequest(request('new', 'terminal.subscribe'), send, ws) + handleRequest(request('stop', 'terminal.unsubscribe'), send, ws) + + vi.advanceTimersByTime(1000) + expect(send.mock.calls.map(([response]) => response.id)).toEqual(['stop']) + }) +}) diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts index a120d569db9..a920f37a7a3 100644 --- a/mobile/src/terminal/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => { expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true) }) + it('accepts SGR left-drag motion sequences but rejects a motion release', () => { + expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2) + expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false) + }) + + it('accepts default-encoding left-drag motion sequences', () => { + expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true) + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}` + ) + ).toBe(true) + }) + it('accepts repeated default mouse wheel sequences', () => { expect( isTerminalGestureInput( diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts index 6b84c4824fe..6af8a2367f9 100644 --- a/mobile/src/terminal/terminal-gesture-input.ts +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -1,8 +1,9 @@ const ESC = '\x1b' const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 +// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel. const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp( - `^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` + `^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` ) function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null { @@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number | const button = bytes.charCodeAt(offset + 3) const col = bytes.charCodeAt(offset + 4) const row = bytes.charCodeAt(offset + 5) + // Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel. if ( - (button === 32 || button === 35 || button === 96 || button === 97) && + (button === 32 || button === 35 || button === 64 || button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index e8409cb3bee..a7715e4f564 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,6 +11,8 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' +import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected' +import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected' import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { @@ -1153,31 +1155,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} return { col: col, row: viewportRow + viewportY }; } - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } + ${TERMINAL_MOUSE_REPORT_CELL_JS} function isAlternateBufferActive() { try { @@ -1648,6 +1626,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). ${TERMINAL_WHEEL_SCROLL_JS} + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + ${TERMINAL_MOUSE_CLICK_DRAG_JS} + btnCopy.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); @@ -1705,6 +1687,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); targetSurface.addEventListener('touchstart', function(e) { if (dispatcherShouldBlockSurface()) return; diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts new file mode 100644 index 00000000000..bc9c12aa016 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -0,0 +1,199 @@ +// Indirect-pointer (external mouse / trackpad) click and drag for the terminal +// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to +// keep that file within its max-lines budget. Companion to +// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); +// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: +// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, +// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, +// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, +// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, +// stopEdgeScroll, and dispatcherShouldBlockSurface. +// +// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events +// with pointerType 'mouse' and NO touch events, while a finger raises +// pointerType 'touch' plus the touch events the document dispatcher owns. The +// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: +// it is what keeps xterm's own mouse handling inert (its onData output is +// dropped by the mobile bridge), and pointer events are unaffected by it. +export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` + var mouseGesture = null; + + // One report per transition, built with the same encoding ladder as + // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' + // when the mode does not report this transition (x10 has no release, only + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect(); + mouseGesture.dismissedSelection = true; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Plain click == touch tap: links and file paths win, then tracking-mode + // click reports, then keyboard focus. + notifyTerminalSurfaceTap(e.clientX, e.clientY); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts new file mode 100644 index 00000000000..bb2494b62e7 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse click', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.mouseClick(40, 60) + + // Default (non-SGR) encoding: press (32=' ') then release (35='#'), each + // ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing. + const bytes = mouse.terminalInputBytes() + expect(bytes).toHaveLength(12) + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) + expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + }) + + it('dismisses an existing selection with a click without focusing the keyboard', () => { + mouse.boot() + mouse.mouseDrag(40, 60, 160, 90) + mouse.clearPostedMessages() + + mouse.mouseClick(240, 200) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([ + { type: 'set-select-mode', enabled: false } + ]) + expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([]) + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false) + }) + + it('routes a plain mouse click to the tap pipeline for keyboard focus', () => { + mouse.boot() + + mouse.mouseClick(40, 60) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('ignores non-mouse pointers and non-left buttons', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 1 + }) + mouse.dispatchPointer('pointerup', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 0 + }) + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 }) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 }) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) + }) + + it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + const touchStart = new Event('touchstart', { bubbles: true }) + // Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it. + Object.defineProperty(touchStart, 'touches', { + value: [{ identifier: 7, clientX: 40, clientY: 60 }] + }) + mouse.terminalSurface().dispatchEvent(touchStart) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 }) + + // Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with + // real touch events; double-handling would double the click report. + expect(mouse.terminalInputBytes()).toBe('') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts new file mode 100644 index 00000000000..e4fac512873 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MOUSE_REPORT_RE, + ESC, + useTerminalMouseWebViewHarness +} from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse drag', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.mouseDrag(40, 60, 160, 60) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.length).toBeGreaterThanOrEqual(3) + expect(reports[0]?.charCodeAt(3)).toBe(32) + for (const motion of reports.slice(1, -1)) { + expect(motion.charCodeAt(3)).toBe(64) + } + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + // Motion reports are deduped per cell, so a horizontal drag advances columns. + const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4)) + expect(new Set(motionCols).size).toBe(motionCols.length) + }) + + it('does not report motion or release for an x10 click-only TUI drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'x10' + + mouse.mouseDrag(40, 60, 160, 60) + + // x10 reports presses only; the press goes out at drag start, no motion or release follows. + const bytes = mouse.terminalInputBytes() + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes).toHaveLength(6) + }) + + it('selects character-anchored text on a mouse drag outside tracking mode', () => { + mouse.boot() + // Why: init itself emits a set-select-mode reset; only the drag matters here. + mouse.clearPostedMessages() + + mouse.mouseDrag(40, 60, 160, 90) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.selectionSpy()).toHaveBeenCalled() + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true) + const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode') + expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }]) + }) + + it('releases a tracked drag when the button state shows the pointerup was lost', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + // Pointer re-enters with the button already up — the pointerup never arrived. + mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + + mouse.clearPostedMessages() + mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 }) + + // The lost pointerup ended the gesture; a later unrelated move must not emit motion. + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointercancel', { x: 160, y: 60 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + // Press went to the TUI, so the cancel must not leave the button latched. + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts new file mode 100644 index 00000000000..14baeeaf325 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts @@ -0,0 +1,29 @@ +// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, +// panX/panY, getCellWidth/Height, and getTotalScale. +export const TERMINAL_MOUSE_REPORT_CELL_JS = ` + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts new file mode 100644 index 00000000000..29500d3c710 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts @@ -0,0 +1,248 @@ +import { runInThisContext } from 'node:vm' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf(' - - diff --git a/docs/orchestration-reset-scope-validation.md b/docs/orchestration-reset-scope-validation.md deleted file mode 100644 index 71c02abae0c..00000000000 --- a/docs/orchestration-reset-scope-validation.md +++ /dev/null @@ -1,89 +0,0 @@ -# Orchestration Reset Scope Validation - -## Problem - -`orchestration.reset` silently clears all orchestration state when no scope is provided. The RPC schema accepts every scope as optional at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:139), and the handler falls through to `db.resetAll()` at [src/main/runtime/rpc/methods/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.ts:585). Existing reset tests only cover explicit single scopes at [src/main/runtime/rpc/methods/orchestration.test.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/main/runtime/rpc/methods/orchestration.test.ts:1024). - -## Root Cause - -`ResetParams` models `all`, `tasks`, and `messages` as independent optional booleans. The handler then chooses the first truthy scope and treats zero truthy scopes as `all`, so `{}` wipes everything and contradictory inputs such as `{ tasks: true, messages: true }` partially apply the first truthy branch. - -## Non-goals - -- Redesign orchestration storage or reset semantics. -- Add confirmation prompts to the RPC protocol. -- Change `resetAll`, `resetTasks`, or `resetMessages` database behavior. -- Change unrelated orchestration commands or provider-specific behavior. - -## Design - -1. Enforce exactly one truthy reset scope at the `ResetParams` schema using `superRefine`. -2. Remove the handler fallthrough to `db.resetAll()` so only validated explicit scopes can mutate state. -3. Preserve the existing CLI no-flag shortcut by having [src/cli/handlers/orchestration.ts](/Users/jinwoohong/orca/workspaces/orca/bug-orchestration.reset-wipes-all-orchestration/src/cli/handlers/orchestration.ts:440) pass `all: true` when no reset scope flag is present. This keeps CLI compatibility explicit while preventing ambiguous direct RPC calls. -4. Let explicit multi-flag CLI invocations reach RPC validation and fail with the shared invalid-argument path. Duplicating scope validation in the CLI is unnecessary because all CLI calls already pass through this RPC method. -5. Add RPC regression tests that seed one message and one task, call invalid reset params, assert rejection, and assert both seeded records remain. -6. Add CLI parser tests that `orca orchestration reset` calls RPC with `all: true`, and that explicit flags are passed through unchanged for RPC validation. - -## Data Flow - -- CLI no-flag path: `orca orchestration reset` -> CLI handler sends `{ all: true }` -> RPC schema validates -> handler calls `resetAll`. -- CLI explicit-flag path: CLI handler forwards the provided flags -> RPC schema validates exactly one truthy scope -> handler calls one database reset method or rejects before side effects. -- Direct RPC path: caller params -> RPC schema validates exactly one scope -> invalid params fail before handler side effects. - -## Edge Cases - -- `{}` rejects and leaves messages and tasks unchanged. -- `{ all: false }` rejects and leaves messages and tasks unchanged. -- `{ tasks: true, messages: true }` rejects and leaves messages and tasks unchanged. -- `{ all: true, tasks: true }` rejects and leaves messages and tasks unchanged. -- `{ all: false, tasks: true }` is valid and resets tasks only; false values are not selected scopes. -- Non-boolean values such as `{ all: "true" }` are transformed to `undefined` by `OptionalBoolean` and must reject unless exactly one real boolean `true` is present. -- Explicit `{ all: true }`, `{ tasks: true }`, and `{ messages: true }` continue to work. -- Remote and SSH callers are covered because the validation sits behind the shared RPC method, not local CLI process state. -- Unknown keys do not select a scope. If strict unknown-key rejection is desired, that is a separate RPC schema policy change and should not be bundled into this fix. - -## Test Plan - -- Unit/RPC: extend `src/main/runtime/rpc/methods/orchestration.test.ts` with invalid reset scope tests covering empty params and multiple scopes with preservation assertions. -- Unit/RPC: keep existing single-scope tests green to prove no regression to valid reset behavior. -- CLI parser: extend `src/cli/index.test.ts` to assert no-flag `orchestration reset` sends `all: true` explicitly. -- CLI parser: assert explicit `--tasks`, `--messages`, and multi-flag invocations are represented faithfully rather than silently normalized by the CLI. -- Type/lint: run `pnpm typecheck`, `pnpm lint`, and targeted Vitest tests for the touched RPC and CLI files. -- Electron/e2e: not required for golden behavior because this is non-UI RPC/CLI validation; Stage 6 should validate via tests and local CLI/RPC behavior instead of app screenshots. - -## UI Quality Bar - -Not UI-visible. - -## Review Screenshots - -No user-visible UI states. Screenshot artifacts are not required; the validation notes should state that UI screenshot review was skipped because the changed behavior is headless RPC/CLI behavior. - -## Rollout - -1. Tighten `ResetParams` validation. -2. Simplify the reset handler to trust validated single-scope params. -3. Make CLI no-flag reset pass `all: true` explicitly. -4. Add RPC regression tests for invalid params preserving state. -5. Add CLI parser coverage for the explicit no-flag shortcut and explicit/multi-flag passthrough. -6. Run targeted tests, typecheck, and lint. - -## Lightweight Eng Review - -- Scope: Kept to reset RPC validation, CLI argument shaping, and regression tests; no storage or UI changes. -- Architecture/data flow: Validation belongs at the shared RPC boundary so CLI, remote, SSH, and direct runtime callers get the same safety contract. CLI no-flag compatibility remains a CLI concern by passing `all: true`. -- Failure modes covered: - - Empty reset params cannot mutate state. - - Contradictory scopes cannot partially apply the first truthy branch. - - Failed validation happens before any database reset method runs. - - CLI shorthand cannot rely on a dangerous RPC fallback. - - CLI multi-flag input fails through the same RPC validation as any other caller. -- Test coverage required: - - `src/main/runtime/rpc/methods/orchestration.test.ts`: reject empty, false-only, and multi-scope params while preserving seeded message/task state. - - `src/main/runtime/rpc/methods/orchestration.test.ts`: existing valid single-scope tests continue passing. - - `src/cli/index.test.ts`: no-flag CLI reset passes `all: true`; explicit flags and multi-flag input remain directly represented. -- Performance/blast radius: No material concern. One tiny schema refinement and branch simplification run only when reset is invoked. -- UI quality bar: Not UI-visible. -- Required review screenshots: None; final validation notes should explain that screenshot capture is skipped for headless RPC/CLI behavior. -- Residual risks: CLI users may still accidentally clear all state with `orca orchestration reset` because the compatibility shortcut remains, but direct RPC callers can no longer do so accidentally and CLI behavior is now explicit in the caller. -- Concurrency/consistency: This change prevents invalid reset requests from entering the mutation path, but it does not make the existing multi-statement reset methods transactional or coordinate concurrent reset/create/send calls across windows. That is acceptable for the issue scope because valid resets are still destructive administrative operations; adding reset serialization would be a separate storage-level change. diff --git a/docs/persist-tree-view-source-control.md b/docs/persist-tree-view-source-control.md deleted file mode 100644 index c377c9b82ca..00000000000 --- a/docs/persist-tree-view-source-control.md +++ /dev/null @@ -1,93 +0,0 @@ -# Persist Source Control Tree View Choice - -## Problem or Goal - -The Source Control sidebar lets the user toggle changes between list and tree views, but the choice is session-local. After remount or app restart, it falls back to list view. Persist this as a per-user setting, not per-workspace state, so a user's preferred source-control layout follows them across repos and worktrees. - -## Current Behavior - -- `SourceControlViewMode` is a local union in `src/renderer/src/components/right-sidebar/SourceControl.tsx:127`. -- `SourceControlInner` already reads global settings from the Zustand store at `src/renderer/src/components/right-sidebar/SourceControl.tsx:329`. -- The source-control view mode is initialized with component-local React state at `src/renderer/src/components/right-sidebar/SourceControl.tsx:486`, hard-coded to `'list'`. -- The tree/list toggle only calls `setSourceControlViewMode` at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2454` and `src/renderer/src/components/right-sidebar/SourceControl.tsx:2457`. -- The chosen mode controls uncommitted entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2859` and branch comparison entries at `src/renderer/src/components/right-sidebar/SourceControl.tsx:2967`. -- Settings are typed in `GlobalSettings` at `src/shared/types.ts:1271`, defaulted by `getDefaultSettings()` at `src/shared/constants.ts:154`, loaded with default merging at `src/main/persistence.ts:1217`, exposed through `settings:get` / `settings:set` at `src/main/ipc/settings.ts:29`, and updated in the renderer settings slice at `src/renderer/src/store/slices/settings.ts:236`. -- `PersistedUIState` exists at `src/shared/types.ts:1704`, but the request specifically asks for the user's setting rather than workspace-specific UI state. - -## Proposed Design - -Add a new global user setting: - -```ts -sourceControlViewMode: 'list' | 'tree' -``` - -Implementation details: - -- Add a shared `SourceControlViewMode = 'list' | 'tree'` type and the field to `GlobalSettings` in `src/shared/types.ts`. -- Add the default value to `getDefaultSettings()` in `src/shared/constants.ts`; use `'list'` to preserve existing behavior for new and upgraded users. -- Reuse the existing persistence path. `src/main/persistence.ts` already merges `defaults.settings` with `parsed.settings`, so older profiles automatically hydrate the new field without a bespoke migration. -- In `SourceControl.tsx`, stop using local `useState('list')` as the durable source of truth. Read the persisted value through a small guard such as `normalizeSourceControlViewMode(settings?.sourceControlViewMode)`, returning `'list'` for missing or invalid values. -- Keep a narrowly scoped optimistic mode in `SourceControlInner`: `optimisticSourceControlViewMode: SourceControlViewMode | null`. The rendered mode is `optimisticSourceControlViewMode ?? normalizedSettingsMode`. -- Select `updateSettings` from the store and update the toggle handler to compute the next value from the current rendered mode, set the optimistic mode immediately, and persist the next value through `updateSettings({ sourceControlViewMode: next })`. -- Track a monotonically increasing write sequence with a ref. Only the latest in-flight write may clear or revert optimistic state, so out-of-order `settings:set` responses cannot make an older click win over the user's latest intent. -- When `settings.sourceControlViewMode` changes from outside this component, clear the optimistic value if there is no newer in-flight write. This lets the authoritative settings snapshot take back over after hydration, Settings import, or another renderer path updates settings. -- Keep tree expansion/collapse state (`collapsedTreeDirs`) local and session-only. Directory expansion is path/worktree-content-specific, while the requested setting is only the global list/tree layout preference. -- Do not add a Settings pane control unless product wants one later. The existing toolbar icon remains the natural place where the user makes the choice; persisting that click is enough for this request. - -The optimistic state is local UI state only; it is not a second persistence channel. It exists because `updateSettings` crosses async renderer/main IPC, and the toolbar should still behave as a normal toggle under slow writes or rapid clicks. - -### Interaction and Data Flow - -```text -Toolbar click - -> derive next mode from current rendered mode - -> optimistic SourceControlInner state updates immediately - -> updateSettings({ sourceControlViewMode: next }) - -> settings:set persists user settings in main - -> renderer store receives authoritative GlobalSettings - -> SourceControlInner clears optimistic state when the latest write settles -``` - -- Happy path: the toolbar flips immediately, `settings:set` returns the saved settings object, and the optimistic value is cleared once the authoritative mode matches the latest requested mode. -- Missing setting: defaults merge in `'list'`; upgraded profiles do not need a migration. -- Invalid persisted value: the renderer guard treats it as `'list'` for display and for the next toggle write, which self-heals persistence on the next user action. -- Write failure: keep the previous authoritative settings object, clear only the latest optimistic value, and let the UI fall back to the last saved mode. Existing `updateSettings` logs the failure; the component does not need a new error surface for this preference toggle. -- Out-of-order writes: if write 1 sets `tree` and write 2 sets `list`, write 1 resolving after write 2 must not clear or overwrite the optimistic `list` intent in the component. - -## Edge Cases - -- Existing profiles with no `sourceControlViewMode` should behave exactly as before: list view. -- Settings may be `null` before hydration; render should not crash. Disable the toggle until settings hydrate so the fallback `'list'` value is never persisted over an existing saved `'tree'` preference. -- If the body renders before settings hydrate, use the guarded list fallback only as a temporary display value. Do not seed optimistic state until the real settings snapshot is available. -- Corrupt or unknown persisted values should normalize to `'list'` at the component boundary. A broader settings migration is unnecessary for this narrowly scoped string preference. -- A settings write failure should not corrupt local or persisted state. Existing `updateSettings` logs errors and leaves the previous settings object intact. -- Rapid toggles should be last-intent-wins from the user's perspective, even if individual `settings:set` IPC responses resolve out of order. -- The choice must apply globally across active worktree switches, repo switches, and local/SSH runtime targets. -- Directory collapse state should not persist globally because tree node keys are derived from file paths and sections; persisting them would leak one repo's shape into another. -- Source control can render both local and remote/SSH worktrees. This setting is renderer/user preference only and must not depend on filesystem paths, runtime target IDs, or workspace IDs. - -## Test Plan - -- Unit: add a focused test for `getDefaultSettings()` in `src/shared/constants.test.ts` asserting `sourceControlViewMode` defaults to `'list'`. -- Unit: add or extend a renderer test around `SourceControl` to verify clicking the tree/list toolbar calls `updateSettings({ sourceControlViewMode: 'tree' })` from the default list state, and then `updateSettings({ sourceControlViewMode: 'list' })` when currently tree. -- Unit: cover the optimistic write sequence with delayed mocked `updateSettings` promises. A rapid `list -> tree -> list` interaction should leave the rendered mode and latest requested update at `list`, even if the earlier `tree` write resolves last. -- Unit: cover `settings === null` hydration behavior: the toggle is disabled before hydration, then reflects a hydrated `'tree'` preference without persisting the fallback `'list'`. -- Unit: cover the normalization helper for invalid values, missing values, and both valid modes. -- Unit: if direct `SourceControl` rendering setup is too heavy, extract tiny pure helpers such as `getNextSourceControlViewMode(mode)` and `normalizeSourceControlViewMode(value)` near the component and test those helpers plus a shallow mocked component interaction. -- Integration-light: `src/renderer/src/store/slices/settings.test.ts` already verifies rebasing local settings to the authoritative `settings:set` response. No new store behavior is required unless implementation changes `updateSettings`. -- Manual/Electron: open Source Control, switch to tree view, switch worktrees and confirm the view remains tree, restart the app and confirm Source Control still opens in tree view, then switch back to list and confirm restart preserves list. - -Playwright coverage is optional for this change. The behavior crosses app restart and local user-data persistence, which is better covered by Electron validation unless there is already a reliable e2e fixture for persistent settings across relaunch. - -## Rollout Order - -1. Add the `GlobalSettings` field and default. -2. Wire `SourceControl` to read a normalized `settings.sourceControlViewMode` with a safe list fallback before hydration. -3. Add optimistic last-intent-wins toggle handling and persist toolbar toggles through `updateSettings`. -4. Add focused unit coverage for the default, normalization, hydration, and async toggle writes. -5. Run `pnpm typecheck`, `pnpm lint`, targeted tests, then manual Electron validation. - -## Ref-OSS - -Not used. The change follows Orca's existing per-user settings pipeline and does not need external editor behavior to resolve the design. diff --git a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md b/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md deleted file mode 100644 index abaa7d108cb..00000000000 --- a/docs/plans/2026-07-27-ssh-relay-pty-backpressure.md +++ /dev/null @@ -1,2949 +0,0 @@ -# SSH Relay PTY Backpressure - -Date: 2026-07-27 - -Status: architecture gate closed and exact SSH V1 wire/API implemented -always-on for new SSH sessions; integrated lifecycle fixes reconciled at -the current working tree; live topology gaps remain explicit below - -Historical unbounded baseline: `badf91101babf96fa09cb79a8294f7e23b9f081c` -(the implementation branch parent). The implementation was rebased onto -`origin/main@d9fec8fd61d0349ae8cdf0eea594de7425db94c9`; final GitHub CI remains -the merge gate. - -## Scope - -This implemented design bounds SSH relay PTY output from the native PTY through the relay -dispatcher, SSH channel or relay socket, Electron main process, and renderer -parser. It covers: - -- application-level PTY output credit; -- relay stdout and socket drain handling; -- per-client isolation, fairness, ordering, and cleanup; -- bounded frame decoding on both sides of the relay protocol; -- memory budgets, diagnostics, compatibility, tests, and deployment. - -The sidebar reconnect fix in `9d3ae3adc7` is out of scope. The non-ancestor -commit `1500a92904` is design input only; it must not be cherry-picked. This -work does not change PTY input semantics, terminal-model interpretation, or -file/Git payload semantics. It does change replay transport, SSH producer -pause wiring, and bulk frame admission where they share the dispatcher sink. - -## Implemented architecture and evidence boundary - -The architecture review approved one shared semantic -`PtyConsumerSession` state machine with an SSH-specific transport adapter, -not a universal wire protocol. The exact SSH adapter contract is now -implemented. In this PR: - -- `PtyConsumerSession` owns authenticated client/owner generations, lease - recovery, capability intersection, and grant publication commit/rollback. -- `SshPtyConsumerSessionAdapter` binds those semantics to the existing - authenticated relay connection and the first framed `pty.openClient` - request. The relay dispatcher remains the only sink-publication authority. -- The relay source ledger owns immutable source spans, outstanding source - credit, token rotation, sealed exit, and cancellation proof. The dispatcher - writer owns byte admission, priorities, `write(false)`, callbacks, and drain. -- Spawn and attach responses carry immutable `sourceActivation` metadata. The - main mux installs a provisional receive lease synchronously in - `beforeResolve`; adjacent source notifications remain private to that lease - until spawn validation commits or a validated reconnect contract transfers - them into its private recovery quarantine. Rollback drops untransferred data - and waits for exact token-cancellation proof. -- `SshRelaySession` owns grant/reconnect state, bounded recovery quarantine, - exact recovery fencing, and cumulative ACK publication through the main SSH - mux. Failed recovery-cancellation publication or proof closes the exact - provider generation and its publishers once. -- The main SSH intake owns one atomic admission across the model, desktop - projection, and required remote consumers. Model completion and consumer - terminality make ACKs eligible; only the mux write callback publishes them. -- Desktop and remote replacement transactions carry immutable span identity. - Reserve happens before publication, commit requires the exact - generation/sequence fence, and failure, stale publication, or already - reclaimed spans reject commit and roll back idempotently without credit. -- Relay `restoreRequired` cancellation retains its exact delivery record until - the metadata response settles, then retires only that record so a retry can - mint a fresh token. Main exit timeout obtains cancellation proof before - transferring projections and committing reclamation, with generation checks - around every finalization step. -- The main-to-relay writer has three FIFO lanes: liveness, control, and - ordinary. Drain reselects liveness/control ahead of queued ordinary traffic, - while one ordinary frame is guaranteed after four control writes. - -The implementation is intentionally scoped: - -| Surface | State in this PR | Evidence | -| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Same-build direct SSH/deployed relay, V1 | implemented; every session offers and relay advertises V1 | current-working-tree deterministic contracts plus provenance-bound macOS-hosted Docker OpenSSH | -| Capability/method-not-found legacy fallback | implemented bounded legacy compatibility | deterministic negotiation and writer contracts | -| Reconnect/owner recovery | implemented | exact contiguous recovery, fail-closed cancellation, eight-wide reattach tests, Docker reconnect | -| Headed/headless remote consumers | source-range rotation/replacement implemented | deterministic runtime/main seam only; no live paired-runtime claim | -| WSL stdio and Windows named pipe/ConPTY | shared transport paths preserved | deterministic/common-contract evidence only; no physical run | -| Local provider and local daemon | unchanged | outside SSH V1 negotiation; existing behavior only | -| Folder workspace | no `.git` dependency added | code-path review only; no dedicated live fixture | -| Prior-version/mixed-version peers | fail-closed or version-scoped legacy behavior preserved | deterministic negotiation/deploy contracts; no live old binary | -| Ubuntu 20.04/glibc 2.31 | no native dependency added | cross-target relay build only; no physical packaging run | - -Docker SSH proves the Linux SSH provider and deployed-relay topology. It does -not prove headed or headless paired-runtime behavior, WSL, Windows ConPTY or -named pipes, local daemon/provider behavior, folder workspaces, prior-version -processes, mixed-version clients, or the Ubuntu 20.04 packaging floor. - -The final exact-code Docker rerun rebuilt the E2E main bundle and all relay -targets from the current-main merge head. It is bound to implementation commit -`adba3410fe4427ceb7525f3fdce2ec58973263a7`, tree -`e43d87340666ca3a733bdb38007c24f95be3f219`, main bundle SHA-256 -`3c304ffc0618520e42bede9a52f72d4b7bb68cbfe543974f89e82a3c477c44b3`, -and deployed Linux-x64 relay SHA-256 -`366cb7ccf2e4b388cc6f81a8f6055ab9fde57d2d91381767005c15908832e776`. -No additional live topology proof is inferred from that run. - -## Verified baseline and migration boundary - -On the historical baseline, the SSH path was not protected by the main pending-data bound: - -1. `node-pty` calls `PtyHandler`'s `onData`. `PtyHandler` keeps the existing - 100 Ki-source-unit replay tail. The first ordinary flush waits 8 ms, a - sustained flush continues after 1 ms, and interactive or transformed output - may publish immediately; a turn publishes at most two 16 Ki-unit chunks. -2. `RelayDispatcher.notify('pty.data')` broadcasts each publication. The - ordinary notification path ignores `process.stdout.write()` and - `Socket.write()` returning `false`, so every later frame can extend a Node - writable queue after the first saturated write. -3. Main's `FrameDecoder.feed()` drains all complete frames synchronously. - `MultiplexerTransport` exposes neither read pause/resume nor write drain. -4. `SshRelaySession.wireUpPtyEvents` is the production SSH delivery owner. It - calls `runtime.onPtyData()` and then sends `pty:data` directly to the - renderer. It does not enter the `PtyPendingDataDrainQueue` installed by - `src/main/ipc/pty.ts` for local and daemon providers. -5. Main therefore has no SSH queue record against which to calculate useful - upstream progress. The current cumulative renderer ACK is converted to a - delta, the SSH path commonly emits zero, and the relay's - `PtyHandler` ignores `pty.ackData` in any case. -6. Production POSIX and Windows deployment launches the relay detached, then - attaches the real desktop bridge through a Unix socket or named pipe. - Dispatcher construction therefore cannot identify the usable session owner. -7. `OrcaRuntime.onPtyData` returns synchronously while headless-emulator writes - append captured strings to an unmetered asynchronous `writeChain`. -8. Relay fs/Git chunks use `notifyBulk`/producer chains outside any stated PTY - reserve; they can occupy the same ordered sink ahead of cancel, ACK, and - interactive output. -9. `computeRemoteRelayDir` content-hash-scopes both daemon files and endpoints. - Mixed deployed binaries are fenced, while a prior-version daemon can remain - alive in its old directory after upgrade. -10. `SshPtyProvider` omits the existing producer-pause hooks, and production - constructs `MultiplexerTransport` in both SSH sentinel handling and the WSL - hook path. -11. Lossless remote terminal streams ACK encoded byte deltas today and retain - only aggregate in-flight bytes; no existing record maps those bytes back to - immutable SSH source intervals. `694363805` extracted their UTF-16 - code-unit-preserving chunker to - `src/main/runtime/rpc/terminal-output-frame-chunks.ts`; it improves - performance and equivalence coverage but adds no provider/token identity. -12. `1fd0f731f` routes SSH folder-workspace automation launches to their owning - host. It does not change PTY output delivery or make folder workspaces Git - worktrees. -13. `077561f89` moved remote terminal stream UTF-8 measurement into - `terminal-stream-byte-length.ts`, preserving legacy flush boundaries and - partial over-limit counts. Those encoded-byte counters remain transport - budgets, not SSH source credit. -14. `2dac0741b` preserves ordered DEC mode 2031 subscribe/withdraw decisions - when the current main pending-data queue drops or salvages renderer output. - Its bounded cross-chunk scan state is projection metadata that a unified - SSH intake must preserve; it does not settle source spans. -15. `d547e278f` adds epoch-scoped, post-delivery watermarks for mobile - notification catch-up. That notification replay protocol is distinct from - mobile terminal streaming; its sequence and epoch cannot identify or settle - PTY source ranges. - -This project first moves ownership of every SSH provider data event out of the -direct `wireUpPtyEvents` send and into one main delivery intake. That intake -performs runtime ingestion, desktop delivery policy, remote-consumer -fan-out, and upstream span settlement exactly once. Adding another listener is -forbidden because it would double-ingest and double-render output. - -The initial 8 ms/continuation 1 ms cadence and two-write limit are scheduling -controls, not bounds. A continuous producer can currently grow the relay -writable queue, SSH buffers, decoder input, and Electron heap. The frame-header -`ack` remains transport liveness bookkeeping; it is not PTY credit and never -enters this ledger. - -The falsifiable invariant is: accepted SSH relay PTY output remains bounded -across relay writable queues, SSH/socket transport, main decoding and model -admission, renderer projection, and remote consumers while every source span -settles exactly once in order. The observable failure on this baseline is that -ordinary relay PTY notifications continue after `write(false)`, main drains all -complete frames synchronously, SSH bypasses `PtyPendingDataDrainQueue`, and -headless emulator writes enter an unmetered Promise chain. A sustained SSH -producer can therefore increase relay, transport, and main memory without a -finite upstream credit owner. - -Authority and delivery boundaries on this baseline are: - -```text -renderer/viewer - -> IPC or runtime RPC - -> Electron main / OrcaRuntime - -> SshRelaySession + SshPtyProvider - -> SshChannelMultiplexer - -> SSH channel / --connect bridge - -> RelayDispatcher - -> PtyHandler - -> node-pty / ConPTY -``` - -`PtyHandler` owns native output and replay; relay dispatcher/adapters own -client writes; `SshRelaySession.wireUpPtyEvents` owns production SSH ingestion; -`OrcaRuntime` owns the headless model; `src/main/ipc/pty.ts` owns -main-to-renderer accounting; and `terminal.multiplex` owns remote-consumer -delivery. The current bug is fragmentation across those owners, not a missing -renderer-only cap. - -## Required invariants - -1. Protocol credit uses one canonical unit: UTF-16 code units in the - pre-transform source span, called source units (`su`). Retained memory and - wire queues use exact bytes and never masquerade as source credit. -2. For a negotiated token, - `0 <= sentEnd - creditedEnd <= windowSu`; each admitted slice fits the - remaining window, so no frame overshoot is permitted. ACKs are cumulative, - monotonic, client/PTY/token scoped, and never exceed `sentEnd`. -3. Source-credit spans are immutable and stored in a cumulative ledger. - Queue data may merge, split, salvage, thin, or coalesce without moving, - copying, or destroying ledger boundaries. -4. A sink `write(false)` accepts its frame exactly once. No ordinary frame is - written again before drain. PTY admission stops before a reserved - control/liveness capacity is consumed. -5. A spawn/attach token remains `activating` until its metadata-only response - crosses the relay sink fence. Main may receive source frames after its - synchronous provisional install, but they remain lease-private and cannot - project. A validated reconnect contract transfers that lease into the - attempt's private recovery quarantine; final commit, model, and desktop - admission remain fenced until all recovery `pty.data` and the exact - completion fence agree. -6. Data is ordered within a token. `pty.exit` follows every accepted data frame - and cannot bypass the sink gate or activation fence. Publishing exit seals - new data but does not retire an uncredited suffix. -7. Every open token-owned span and consumer obligation ends exactly once in - `settled`, `transferred`, or `canceled`; a transfer may pass through the - non-terminal `transferring` state only while its named replacement fence is - outstanding. A canceled `restoreRequired` delivery remains addressable until - its exact response settles and retires only if the map still names that - record. Records are never merely abandoned while their relay token can live. -8. Desktop, mobile, web, and agent-session consumers follow the explicit - settlement policy below. A stalled recoverable desktop projection cannot - freeze a healthy lossless remote view. -9. One slow additional subscriber cannot retain unbounded data or stop a - healthy subscriber. The negotiated session owner is not torn down for one - PTY's backlog; constructor position never grants that role. -10. When every delivery for a PTY is blocked, pause the native PTY. Resume only - when both local and relay-wide low-water predicates hold. -11. An unexpected client loss transfers outstanding output to a bounded - reconnect-grace owner. Normal subscriber absence retains only the existing - replay tail and no live queue. -12. Decoder work, decoded bytes, model admission, and activation-hold turns are - bounded. A self-imposed read or local-write pause rebases both liveness - clocks. Main-to-relay drain reselects liveness, then control, ahead of - ordinary PTY input; four consecutive control selections force one ordinary - selection when both lanes remain non-empty. -13. Disconnect, provider replacement, renderer reload, exit, disposal, and - workspace removal have explicit bounded cleanup. After their required - publication, transfer, cancellation, or generation-close proof, they leave - no open span, token, writer callback, drain waiter, timer, cursor, or paused - PTY. Recovery-cancellation publication failure or rejected proof fails - closed by closing only the owning provider generation and disposing its - mux, provider, publishers, transferred activation state, and registration - once. -14. All counters are finite safe integers. A transformed frame requires a - valid `rawLength`; malformed, excessive, stale, and cross-client values - cannot create credit or crash either process. -15. The session owner is elected by an authenticated session grant and - identified by an owner generation. It is never inferred from dispatcher - construction, stdout, socket order, a path, `.git`, or a worktree. -16. Recovery and serialization control responses contain metadata only. - Source-ranged recovery or snapshot bodies use bounded producer lanes and - explicit completion fences before live delivery. -17. The required main model has bounded asynchronous admission. Relay credit - cannot advance while data waits outside that charged admission or after an - emulator failure. -18. Negotiated V1 has one upstream ACK owner. Renderer projection progress and - remote encoded-byte ACKs settle ledger obligations but never emit legacy - SSH ACK deltas. -19. Required obligations becoming terminal, cumulative ACK queueing, and ACK - publication are three monotonic states. A write callback publishes already - eligible credit; it never creates eligibility. -20. Desktop admission carries immutable span identity through model reserve, - projection queue, renderer send, ACK, salvage, reload, and replacement. - Failure either rolls back an uncommitted transaction or transfers a - committed obligation with proof. Exit timeout acquires cancellation proof - without mutation, transfers published projections, commits proof - reclamation, prepares/finalizes exit once, and rechecks the provider - generation before each post-proof mutation. -21. Projection drop, thinning, salvage, restore, and replay preserve current - main's ordered terminal side-effect facts and bounded scanner state, - including DEC mode 2031 subscribe/withdraw decisions. Pre-commit rollback - restores the prior scanner snapshot, committed transfer moves projection - state exactly once, and an explicit source gap resets cross-chunk state; - none of these facts creates source credit. -22. The immutable delivery identity is exactly `(id, providerGeneration, -clientGeneration, ownerGeneration, ptyIncarnation, deliveryToken)`. Each - admitted source span additionally fixes `spanId`, source/display half-open - ranges, transform metadata, and model-sequence end where required. -23. Receive activation begins at `checkpointSourceEndSu`. Every recovery - `pty.data` frame is contiguous from that cursor through `recoveryEndSu`; - only the exact `pty.recoveryComplete` writer settlement opens live output. -24. A reentrant cumulative ACK may reserve only the exact pending send - boundary for the same delivery identity. Failed send settlement retains - that boundary and permits only an exact same-token retry. -25. A fixed `fs.streamChunk` keeps the protocol's 256 KiB payload and offset - semantics. It may use the fixed-frame lane only from an empty, - unsaturated producer epoch; ordinary PTY and reshapable bulk frames still - obey the non-reserved capacity. -26. A lossless remote stream may advance its cumulative encoded-byte ACK to an - interior byte offset. Exact byte budget is released immediately, while the - covering immutable source frame remains unsettled until its complete - encoded boundary is crossed. -27. A lossless remote stream accepts an in-place source-token rotation only - when PTY ID, provider generation, and incarnation match; both client and - owner generations strictly increase; the token changes; and source/display - coordinates remain contiguous. Old or partially increasing identities are - rejected. -28. Snapshot replacement commits are idempotent and never synthesize byte or - source credit. Duplicate/closed ledger commits are no-ops; if cancellation - proof already reclaimed a reserved span, source replacement commit rejects, - rollback skips the absent span, and settle/detach prune cached mappings. -29. Exact exit seals a provisional or private-recovery receive lease before - any later same-token frame can reserve or project. Exit publication waits - for the held contiguous prefix to admit or for cancellation proof whose - sent end covers every privately observed exact-token frame. -30. Transfer does not consume rollback authority until the ledger proves the - lease is still current and installs the private recovery sink. A stale - transfer can still retire and cancel only its own token. -31. Canceled-frame suppression retains at most one latest ordered delivery - identity per relay PTY. Transport ordering makes earlier token records - obsolete when a later cancellation is observed; exact next activation, - PTY exit, or provider teardown clears the remaining record. - -## Protocol - -### Architecture decision record - -Decision: `PtyConsumerSession` is a shared semantic state machine, not a -universal transport protocol. The architecture gate is closed and is no -longer waiting for exact wire/API names: the SSH adapter implements -`sourceActivation`, source-bearing `pty.data`, `pty.recoveryComplete`, -cumulative `pty.ackData`, and token-scoped cancellation. Current-working-tree -deterministic validation is an engineering gate; unexecuted live topologies -remain separate promotion gates. A relay-only -`pty.getCapabilities` followed by `pty.negotiateClient` remains rejected -because it would create a second readiness authority beside the existing -authenticated handshakes. - -The 2026-07-28 architecture/terminal review closed its three blocking -findings as follows: - -| Finding | Implemented resolution | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Exited delivery removed while uncredited spans remain | Native exit seals the token; logical state survives until cumulative ACK, exact transfer, token-scoped cancellation proof, or generation close. | -| Terminal obligations depended circularly on ACK publication | Obligation terminality advances `obligationsTerminalEndSu`; the coalescer independently queues `ackQueuedEndSu`; only mux write settlement advances `ackPublishedEndSu`. | -| Desktop projection admission lacked immutable range identity and rollback | Model, source span, projection range, and scanner snapshot reserve atomically; pre-commit failure rolls back, while post-commit replacement transfers with proof. | - -Later adversarial review also required exact recovery continuity, stale-attempt -isolation, session-grant fallback semantics, and idempotent remote detach -after token reclamation. Those are incorporated in the activation, lifecycle, -cleanup, tests, and compatibility sections below. - -The final 2026-07-28 architecture/terminal re-review found and closed three -additional interleavings: - -| Finding | Implemented resolution | -| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Failed exit publication could discard a sealed delivery before its suffix was credited | Failed publication retains the sealed delivery; late cumulative ACK and exact owner recovery can still settle it, and exit is republished without reopening source admission. | -| Recovery-response cancellation authority ended at enqueue rather than writer settlement | The request remains cancellation-authoritative until its response settles; retry reuses the same provisional replacement identity instead of creating an unfenced owner. | -| Snapshot replacement transferred every pending remote span at one sequence fence | Each immutable span retains its model-sequence end; replacement transfers only spans covered by the authoritative `SnapshotEnd`, while trailing spans remain live and replay in order. | - -Current-working-tree adversarial reconciliation closed these additional integrated -failures without widening the approved topology: - -| Finding | Implemented resolution | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Invalid-checkpoint `restoreRequired` left a canceled delivery blocking retry | Keep the exact canceled record through response settlement, then delete it only if it is still current; retry mints a fresh activation token. | -| Recovery cancellation publication/proof rejection left ambiguous authority | Raise a typed recovery-cancellation failure and close the exact provider generation, disposing its mux, provider, publishers, lease, and registration exactly once. | -| Remote partial ACK or higher-generation token rotation detached the stream | Release exact cumulative byte credit at any valid interior offset without source settlement; admit only contiguous strictly higher client/owner generations with a new token. | -| Snapshot cleanup raced cancellation-proof reclamation | Make ledger replacement commit idempotent and make remote replacement commit reject reclaimed spans while rollback, settle, and detach prune them without dereference or new credit. | -| Drain resumed the main mux's old ordinary FIFO ahead of control | Select liveness, control, and ordinary from separate FIFO lanes; prioritize control after drain and force one ordinary write after four controls. | -| Same-turn source data escaped a provisional activation | Queue data privately under the provisional lease; commit publishes in order, while rollback drops it, restores only the active predecessor, and awaits exact cancellation proof. | -| Exit cancellation proof reclaimed spans before projection transfer | Acquire proof first, transfer projections, commit proof, prepare/finalize once, and fence every post-proof step against provider-generation close. | -| Exit during private recovery could publish before held frames settled | Seal the private lease immediately, wake the exact recovery fence, then either admit the ordered prefix before exit or retire it only after exact token-cancellation proof. | -| A superseded transfer lost its cancellation authority | Keep the outer lease unsettled until transfer ownership is verified; a stale transfer can still roll back and request cancellation for its exact token. | -| Recovery proof watermark skipped rejected or post-restore frames | Observe every exact-token private frame before retention or restore checks and reject any cancellation proof whose sent end is below that immutable high-water. | -| Canceled-token tombstones grew once per token | Retain only the latest ordered canceled token per relay PTY, replace it on later cancellation, and clear it at exact next-activation, PTY-exit, or provider-teardown boundaries. | -| Reconnect checkpoint raced a running raw model callback | Freeze the exact provider-generation/PTy admission, cancel only queued work, and await the bounded running callback before exporting a checkpoint. | -| Migration-owned callback failure closed the shared provider | Contain failure to the exact migrating PTY, reset only its model, preserve siblings and transport, and retain the ordinary generation-fatal policy outside migration. | -| Closed-generation telemetry omitted active gaps | Compact exact closed ranges and expose the count of allocated but unclosed provider generations below the closed high-water. | - -The final exact-implementation-head architecture/lifecycle review was clean -across 27 files and 296 tests. The transport/topology review confirmed the -exact-PTY containment fix and found no remaining code blocker; its sole -evidence finding was the stale artifact record replaced by the -provenance-bound build and Docker run above. - -The common state machine accepts only: - -- an authenticated principal and owner-eligibility decision from the adapter; -- a consumer generation and optional capability offer/grant; -- a subscription/delivery identity and close reason; -- an adapter-provided publication fence. - -It does not authenticate sockets, parse frames, own sentinel/residue bytes, -wait for stream drain, prove reconnect credentials, or define remote-runtime -encoded-byte ACKs. Those remain adapter responsibilities. The semantic input -and output may be represented as: - -```ts -type PtyConsumerSessionHello = { - clientInstanceId: string - requestedRole: 'session-owner' | 'subscriber' - resume?: { - ownerGeneration: number - ownerLease: string - } - capabilities?: { - outputFlowControl?: { versions: [1]; requestedWindowSu: number } - } -} - -type PtyConsumerSessionGrant = { - serverBuildId: string - clientGeneration: number - role: 'session-owner' | 'subscriber' - ownerGeneration?: number - ownerLease?: string - capabilities?: { - outputFlowControl?: { version: 1; windowSu: number } - } -} -``` - -These types are not wire schemas. Authentication produces a transport-bound -principal and `allowSessionOwner` before the state machine sees the offer. A -request field cannot self-promote a client. The state machine performs -generation allocation, owner replacement, capability intersection, -publication fencing, and close cleanup; the adapter proves identity, carries -the semantic fields, and calls the fence. - -The reviewed decision preserves the existing connection machinery: - -| Path | Authentication and identity | Binding in this PR | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| Local in-process provider | trusted main-process construction | unchanged; it does not negotiate SSH V1 | -| Local daemon | token-authenticated `HelloMessage`, stable `clientId`, paired control/stream sockets, and `daemonIdentity` | unchanged; an exact-version daemon remains outside the SSH wire contract | -| SSH relay socket/named pipe | existing authenticated relay endpoint and dispatcher client identity | first framed `pty.openClient`; response settlement is the readiness fence | -| Primary relay stdio and WSL child stdio | no authenticated dispatcher principal in this PR | bounded legacy transport only; an unproved client cannot become session owner | -| Remote-runtime server | paired-device/E2EE identity and connection ID | source-range reserve/commit/rollback only; never receives an SSH owner lease | - -The implemented owner path is the authenticated detached relay endpoint used -by the SSH provider. After extracting the exact sentinel and residue, main -sends `pty.openClient` before spawn, attach, or ordinary dispatch. The relay -adapter queues later decoded frames behind the session response's write -callback, so an eager next request cannot race activation. Main marks the -provider ready only after receiving the valid response. - -Detached `--connect` first reads the version-scoped endpoint credential and -authenticates its Unix-socket/named-pipe bridge before emitting its sentinel. -The adapter converts that proof into a principal and -`allowSessionOwner = true`; `pty.openClient` carries no secret to the generic -RPC handler. The primary relay stdin/stdout dispatcher identity remains -explicitly unproved and therefore subscriber-only; there is no launch nonce in -this implementation. WSL child stdio shares the bounded decoder/writer -transport changes but does not negotiate an SSH V1 owner in this PR. A manual -relay without the version-scoped endpoint credential is also subscriber-only. - -POSIX hosts may expose different shell and SFTP home namespaces. While the -install lock is held, a per-launch marker maps the canonical shell credential -path to the matching SFTP-relative path; the credential write uses that -mapping before the lock is released. If the marker is unavailable or its -creation fails with confirmed termination, main generates the credential -remotely with the selected Node binary at the canonical shell path. System SSH -keeps its shell-path writer, Windows keeps its native secure writer, and the -remote CLI launcher receives the same credential-file path. An unconfirmed -marker teardown retains the lock rather than authorizing a competing writer. - -The review compared sentinel extension and the single request against one -readiness authority, no first-spawn race, authenticated owner replacement, -response publication fencing, reconnect idempotency, exact residue transfer, -and deterministic testability. The single request wins for the authenticated -deployed-relay bridge. A separate capability probe, two-step negotiation, and -a generic cross-transport wire API are not approved. - -The local daemon adapter is deliberately not changed in this PR. Its existing -sequential control/stream hello, authentication, exact-version fencing, and -prior-version isolation remain authoritative. Reusing the semantic state -machine there is a later design decision and is not required to ship the SSH -bound. - -Every new SSH session offers V1, and every relay from the same build advertises -V1. The server intersects the offer with its supported versions and clamps -`windowSu`; an older client that omits the capability receives bounded legacy -delivery while still establishing the role and generation. Main accepts only -a returned version it offered and a finite safe integer satisfying -`0 < windowSu <= requestedWindowSu`. An absent or invalid V1 grant after -`pty.openClient` succeeds closes that connection attempt rather than silently -downgrading it. - -For compatibility with a legacy relay that does not implement -`pty.openClient`, main permits token-free bounded legacy delivery only for the -narrow same-build-validated JSON-RPC method-not-found result. Other errors, -missing grants after a successful method call, and unproved version skew fail -closed. This fallback does not change the normal same-build contract: the -session offers V1 and the relay advertises and grants it. - -“Bounded legacy” is transport backpressure, not a hidden source-credit mode. -It creates no delivery token, source window, or cumulative ACK obligation, and -the relay continues to ignore legacy `pty.ackData`. Slice 3 retains at most -2 MiB of ordinary publications per client and 32 MiB across the relay, with -1 MiB/24 MiB low waters, plus at most one 128 KiB producer-held frame per PTY. -At a cap, `PtyHandler` pauses the PTY; accepted write callbacks/drain remove -transport publications, and crossing both low waters resumes it. Slice 2/4 -read pauses propagate through SSH until the relay writer saturates, so stalled -model admission remains bounded without pretending renderer ACKs reached the -relay. - -Legacy exit waits only for preceding transport publications and the exit write -callback; main's receive-exit barrier below still waits for its admitted model -and projection work. Transport close cancels the connection's retained -publications and reconnect uses the existing replay/restore behavior. It may -pause all legacy subscribers behind one slow connection because legacy mode -has no authenticated source-owner role, but it neither drops output nor grows -without bound. These mechanics remain available to negotiated legacy peers. - -The session hello creates no PTY token. A later V1 spawn/attach returns a fresh -`deliveryToken` and creates a subscription only for the authenticated -transport client and installed generation. `pty.attach` must receive request -context just as `pty.spawn` does. Spawn failure, identity mismatch, stale -context, and response cancellation create no active V1 subscription. A legacy -spawn/attach returns the current identity/replay shape and creates only the -bounded transport subscription above—no token or source coordinate. A live V1 -token never rotates into legacy service; disconnect closes it before any -separately negotiated legacy fallback connection can open. - -Session ownership is granted by the authenticated session hello, not -constructor-assigned: - -- fresh relay deploy writes one endpoint credential beside the versioned - endpoint, reusing the local daemon's token-authenticated-hello pattern rather - than inventing a second claim protocol. It has mode `0600` on POSIX or a - current-user ACL on Windows. `--connect` proves it; a plain dispatcher socket - without the credential is subscriber-only. Primary relay stdio and WSL - child stdio remain unproved bounded-legacy transports. The adapter converts - proof into `allowSessionOwner = true` and does not expose the credential to - RPC handlers; -- the main creates one opaque `clientInstanceId` for its relay-session - lifetime. The first authenticated owner hello when no owner exists binds - that identity, returns a lease, and starts owner generation 1; -- until that first grant crosses its write fence, a retry with the same - authenticated identity is idempotent and any competing identity is rejected. - Once fenced, replacement requires both the current lease and expected - generation; -- an unexpected owner disconnect retains that lease and its PTY grace cursors - for 30 seconds. A reconnect presenting the lease and expected owner - generation atomically increments the generation, rotates the lease, replaces - the old client, and transfers grace ownership before any new token activates; -- a same-generation retry is idempotent only for the same live client. - Competing, stale, lease-less, or unauthenticated identities cannot replace - the owner and may connect only as ordinary subscribers; -- after grace expiry, the old owner lease is invalidated and the next - owner-authorized session hello can elect a new owner at the daemon's next - monotonic owner generation; generations are never reused. Ordinary - subscribers never inherit owner teardown protection merely by arriving first. - -Source activation, `pty.data`, recovery completion, ACK, and cancellation -carry client/owner generations and the delivery token. `pty.exit` retains its -existing PTY ID plus incarnation shape and resolves against the already -installed delivery identity; it cannot create or rotate one. Main rejects a -generation other than the one installed by its synchronous response hook. The -constructor stdout client is never implicitly a session owner. Production -launches detach on POSIX and Windows, invalidate stdout, and connect the -desktop bridge through `attachClient` over the versioned Unix socket or named -pipe. Unproved direct stdio remains subscriber-only. - -Capability support is negotiated on every SSH session. `SshRelaySession` -always offers V1 during initial connection and automatic reconnect, and POSIX -and Windows relays always advertise it as an unconditional capability. Sink -drain, writer ordering, decoder bounds, and header-ACK hardening remain -unconditional correctness fixes. - -Production deployment does not form arbitrary mixed-build main/relay pairs. -`computeRemoteRelayDir` content-hash-scopes the install directory and its -socket/named-pipe endpoint, and the `.version` handshake is a second fence, so -the desktop bridge and daemon reached at that endpoint share a build. The -normal same-build mode is session-granted V1. A missing mandatory session grant -fails readiness except for the narrow method-not-found compatibility fallback -above. Protocol tolerance for unknown fields remains for direct/manual relay -launches, but an absent session contract outside that fallback is diagnostic -`unsupported-version-skew`, not a deployment cohort. - -The reachable upgrade skew is an orphaned prior-version daemon in its old -version directory with live PTYs while the new main connects to a new endpoint. -It retains its old behavior until its own grace/cleanup completes and is not -reachable by a new same-build session. Upgrade diagnostics enumerate -these versioned orphan processes; V1 neither adopts their PTYs nor claims to -bound their memory. - -Unknown fields are never capability proof. Place the transport-neutral state -types with the narrowest shared session package, while relay/daemon/runtime -wire types remain in their current protocol packages. Do not make the local -provider depend on relay framing for code reuse. - -### Data and ACK schema - -Flow-controlled output is: - -```ts -// relay -> one subscribed client -{ - jsonrpc: '2.0', - method: 'pty.data', - params: { - id: string, - ptyIncarnation: string, - data: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - sourceEndSu: number, - sourceLengthSu: number, - seq?: number, - rawLength?: number, // required and equals sourceLengthSu when transformed - transformed?: true - } -} -``` - -For untransformed frames, `sourceLengthSu = data.length` and `rawLength` is -absent or equal. For `transformed: true`, `rawLength` is required, finite, -safe, non-negative, and equals `sourceLengthSu`; display length is never used -as source credit. A violation cancels the token as malformed. `sourceEndSu` is -the monotonic cumulative coordinate within one opaque `ptyIncarnation`, not a -display offset. A new token starts with `sentEndSu == creditedEndSu` at its -declared checkpoint, so its absolute coordinate still satisfies the window -equation. The source interval is -`[sourceEndSu - sourceLengthSu, sourceEndSu)`. It is independent of `seq`, -which remains the terminal-model source sequence. - -The wire does not send `providerGeneration` or `spanId`. Main supplies its -locally allocated provider generation and derives -`spanId = deliveryToken:sourceStartSu:sourceEndSu`; together with `id`, -client/owner generations, incarnation, token, source/display ranges, and -transform metadata, that forms the immutable admission identity. No later -queue, snapshot, ACK, detach, or replacement path may reconstruct identity -from PTY ID, byte count, or display length alone. - -ACKs are cumulative and coalescible. Main holds the latest settled end per -token and emits at most one batched notification per SSH session every 8 ms, -or immediately when any token frees at least 64 Ki su: - -```ts -{ - jsonrpc: '2.0', - method: 'pty.ackData', - params: { - acknowledgements: Array<{ - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - creditedEndSu: number - }> - } -} -``` - -One frame contains at most 64 latest-value entries; another turn handles the -remainder. Replacing a queued entry for the same token is lossless because the -value is cumulative. ACK frames use the reserved control lane and never one -frame per data frame. - -The relay normally accepts an entry only from the owning dispatcher client and -only when `creditedEndSu` is a finite safe integer satisfying -`previousCreditedEnd <= creditedEndSu <= sentEnd`: - -```text -if creditedEndSu > previousCreditedEnd: - creditedEnd = creditedEndSu -``` - -Negative, non-finite, unsafe, fractional, over-credit, wrong-client, wrong-PTY, -unknown-token, and stale-token values are rejected and counted diagnostically; -they never clamp into valid credit. Duplicate and regressing values are -no-ops. Token generation, not PTY ID reuse, defines the credit lifetime. - -The writer callback is not guaranteed to run before main processes and ACKs an -accepted frame. If an ACK reaches the relay while that exact same-token send -reservation is pending, the ledger records only its exact source-end boundary; -`sentEndSu` and `creditedEndSu` do not advance yet. Successful send settlement -commits the boundary and immediately applies the reservation. Failed -settlement rolls back the send but retains the reserved boundary, so the next -send must reproduce that exact boundary and cannot admit a shorter or later -slice. Cross-token, interior-boundary, and excessive early ACKs remain errors. - -Explicit cancellation is a request so main receives proof: - -```ts -// main -> relay -{ - method: 'pty.cancelDelivery', - params: { - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number - } -} -// result -{ canceled: true, sentEndSu: number, creditedEndSu: number } -``` - -The relay validates client/PTY/token ownership, changes the token to `closing`, -removes its cursor and activation/exit fences, then responds through the control -lane. Duplicate cancellation of the same recently closed token is idempotent. -Main may discard open obligations only after this response drains back through -the mux or the client-generation close proves equivalent cleanup. - -Every relay-initiated token close emits this metadata-only control -notification before the token record is forgotten: - -```ts -{ - method: 'pty.deliveryCanceled', - params: { - id: string, - deliveryToken: string, - clientGeneration: number, - ownerGeneration?: number, - reason: string, - sentEndSu: number, - creditedEndSu: number, - remainingStartSu: number, - remainingEndSu: number, - replacementDeliveryToken?: string - } -} -``` - -It covers supersession, activation/exit timeout, reconnect-grace expiry, and -explicit delivery cancellation. `remainingStartSu == creditedEndSu` and -`remainingEndSu == sentEndSu` state the exact source interval still unsettled -at the relay. If the sink cannot drain the proof, its generation close is the -proof. Without a replacement, main cancels the matching remaining obligations -and schedules restore/reattach. With a replacement, it keeps those obligations -in `transferring` until replacement `pty.data` and its fence prove exact contiguous -coverage, then atomically transfers the covered suffix. Any prefix already -included in the proved model checkpoint is canceled as superseded without -re-ingestion; an uncovered or mismatched remainder cancels with -`restoreRequired` instead of being credited. Every source subrange therefore -settles once. Stale generations are ignored. - -### Activation, replay, and idempotent spawn - -Token creation and response publication are one fenced operation. The exact -activation metadata carried by V1 spawn and attach responses is: - -```ts -type PtySourceReceivingActivation = Readonly<{ - status: 'pending' - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string - checkpointSourceEndSu: number - recoveryEndSu: number -}> - -type SpawnResult = { - id: string - incarnationId: string - sourceActivation?: PtySourceReceivingActivation -} - -type AttachResult = { - incarnationId: string - replay?: string - sourceActivation?: PtySourceReceivingActivation - sourceRecovery?: - | PtySourceReceivingActivation - | Readonly<{ status: 'restoreRequired'; reason: string }> -} -``` - -The dispatcher places the metadata-only response on the response lane and -invokes the request's `onResponseSettled` callbacks only from the exact writer -settlement. Success clears relay activation; failure cancels a fresh token or -leaves the exact recovery attempt available to its proved replacement. -Enqueue, `write(true)`, and `write(false)` alone are not activation proof. - -The main mux supplies the receive fence. Spawn and attach requests register a -synchronous `beforeResolve(result)` hook in their pending-request record. -`handleResponse` parses the whole response and provisionally installs -`sourceActivation` before resolving the Promise. The lease captures the exact -prior receive state, candidate token, and a private ordered list of same-turn -source frames. Spawn and non-recovery attach commit only after operation-ID -result, incarnation, claimed-owner, attach identity, stale-attempt, and exit -validation. Reconnect first validates the owner, incarnation, token, -checkpoint, and recovery end, installs the exact private quarantine, then -transfers the lease to its private sink before awaiting the body/completion -fence. That transfer cannot update ordinary provider listeners or -`livePtyIds`, and it cannot reach the model, runtime, desktop, or remote -consumers. Final commit switches future exact-token frames to ordinary -publication only after the body, fence, model admission, and activation -validation succeed. Any failure before transfer -retires the candidate and drops its held data, restores only the nearest -non-retired predecessor, then waits for a valid `pty.cancelDelivery` response -from the originating mux. After transfer, recovery cancellation or -provider-generation close owns cleanup; rollback is no longer authoritative. -It cannot erase a newer revision or cancel a newer token. An identical -already-committed activation is an idempotent no-op; an identical provisional -activation is stale. Fresh-spawn rollback that cannot prove cancellation -surfaces `execution_owner_unavailable` rather than claiming the old token -retired. - -The receive cursor starts at `checkpointSourceEndSu`, not -`recoveryEndSu`. Source-bearing notifications for the installed identity may -then advance only contiguously. Frames through `recoveryEndSu` stay -quarantined as recovery; later frames stay held until the exact completion -fence commits. Unknown or mismatched source identities are rejected before -`livePtyIds`, runtime, renderer, or source obligations change. Exact -source-bearing frames carry their immutable `ptyIncarnation` directly and do -not invoke or mutate the legacy incarnation resolver. - -Every exact-token private frame advances a monotonic observed-source high-water -before quarantine retention or `restoreRequired` checks. If exact exit arrives, -main wakes the recovery fence and seals the private lease immediately. It may -publish exit only after the held contiguous prefix is admitted, or after -`pty.cancelDelivery` proves `sentEndSu` covers that high-water and the accepted -checkpoint remains the credited end. Capacity-rejected and post-restore frames -therefore cannot disappear below the cancellation proof. - -Output produced while a token is activating is retained by its bounded shared -cursor and can pause the native PTY; it is never sent early. Output arriving -after main's synchronous install but before authority commit is bounded by the -provisional lease and is not visible to runtime, desktop, remote consumers, or -`livePtyIds`. Main drops unknown-token notifications before any side effect. - -Before issuing attach or any same-client replacement, main serializes each -`(providerGeneration, appPtyId)` through a migration fence: freeze new -old-generation intake, cancel queued-but-unstarted model entries, await the one -in-flight emulator callback, then record the last completed receipt. A later -overlapping reconnect preserves and awaits an earlier fence for the same PTY; -an empty newer generation cannot expose the earlier checkpoint. Before the -deadline, every old callback remains owned by its admission and generation -until raw completion settles. Stale-owner retry invalidates the checkpoint -value but retains the same fence, then requests restore after it settles. If -the 10-second migration deadline expires or the callback fails, admission -checks the exact active migration before its generation-fatal default. Main -cancels only that admission, detaches that PTY's old headless model, prefers -provider/renderer snapshot restore, and requests `checkpointUnavailable`; -recovery reports `restoreRequired` instead of replaying a guessed gap. The -shared mux, sibling PTYs, filesystem, and Git traffic remain live. Outside an -active exact-PTY migration, the same callback failure still closes its provider -generation. Late raw completion stays on the detached model and cannot advance -the canceled source checkpoint. - -Attach sends `sourceRecovery: { status: 'checkpoint', clientGeneration, -ownerGeneration, ptyIncarnation, deliveryToken, acceptedSourceEndSu }`. A -queued, in-flight, or merely rendered span is not a checkpoint. Without a -proved fence it sends `{ status: 'checkpointUnavailable' }`. - -The relay accepts the checkpoint only for the same incarnation and within -`[oldCreditedEndSu, retainedLiveEndSu]`. It never clamps a value forward, -trusts a value beyond sent/retained data, or replays freed pre-credit source; -an invalid or uncovered checkpoint cancels the attempted delivery and returns -the token-free `restoreRequired` arm. The canceled record remains installed and -cancellation-authoritative until that metadata response settles. Settlement -deletes it only if the delivery map still points to the exact record and wakes -capacity; a retry can then mint a fresh token. It does not activate a -subscription or stream a partial gap; main either restores an authoritative -model generation before retry or surfaces the gap. - -The reconnect coordinator consumes `restoreRequired` and owns snapshot-backed -replacement. A generic existing-session provider reattach has no such recovery -owner, so it maps `restoreRequired` to `SSH_SESSION_EXPIRED` instead of -returning an outputless live PTY; the existing renderer fallback may then -replace that stale session. - -Recovery bodies use the ordinary source-bearing `pty.data` schema. The new -token initializes `sentEndSu = creditedEndSu = checkpointSourceEndSu`, and -the retained gap covers exactly -`[checkpointSourceEndSu, recoveryEndSu)`. Each frame consumes the normal -window and bounded model admission; there is no separate recovery body method, -stream ID, or replacement-only snapshot kind on this SSH wire. - -Only after the last recovery `pty.data` writer callback succeeds does the relay -attempt this metadata-only control notification: - -```ts -{ - method: 'pty.recoveryComplete', - params: { - id: string, - clientGeneration: number, - ownerGeneration: number, - ptyIncarnation: string, - deliveryToken: string, - checkpointSourceEndSu: number, - recoveryEndSu: number - } -} -``` - -`recoveryCompletionPending` permits one admitted attempt at a time. If control -capacity rejects it before admission, the record retains both range ends and -the capacity callback retries; repeated capacity signals cannot enqueue -duplicates. Only the completion notification's successful writer settlement -clears recovery state and permits buffered live `pty.data`. A failed -settlement leaves recovery fenced for exact owner replacement or cleanup. - -Notification-style `pty.replay` remains the legacy attach compatibility path. -`pty.serialize` remains its existing bounded metadata response; neither is a -second V1 source-recovery body channel. - -Main quarantines the entire candidate recovery transaction until it can prove -the fence. The first source-bearing recovery range must start at the accepted -checkpoint, every later range must be exactly contiguous, and the final end -must equal `recoveryEndSu`. Empty recovery is valid only when the checkpoint -already equals `recoveryEndSu`; main retains that end as the required start of -the first later live frame even after activation. The first live frame and -every successor must also be exactly contiguous. Gap, overlap, incomplete -suffix, missing body, bad empty recovery, or bad live handoff reaches neither -the model nor desktop projection. - -A failed or stale recovery attempt cancels only its own replacement token. -It never sends cancellation on a newer mux, clears a newer checkpoint, shuts -down the physical PTY/process, deletes PTY ownership, or expires the owner -lease. A valid matching token-scoped cancellation proof is applied locally and -leaves the lease detached and retryable. If the current attempt cannot publish -cancellation or local proof application rejects, the session fails closed: -it closes the exact provider generation with -`ssh_source_recovery_cancellation_failed`, disposes that generation's mux, -provider, ACK/cancellation publishers, provisional lease, and registration -once when still provisional, or its transferred activation state after -quarantine ownership begins, and reconnects without clearing physical PTY -ownership. If the attempt is already stale, it performs no cleanup against the -replacement. A fresh -relay that no longer retains the cached owner returns typed error `-32041`; -main clears only the cached owner/checkpoints and retries `pty.openClient` -exactly once without `resume`. - -Idempotent agent-session spawn has two layers. The cached -`agentSessionCreateOperationId` promise returns only the physical PTY -identity/outcome. After that promise resolves, every current, non-stale outer -`pty.spawn` request creates its own requesting-client subscription and -activation fence. Creating a token for an existing -`(clientId, clientGeneration, ptyIncarnation)` is one atomic supersession -transaction: create the replacement cursor first, move the old token to -`closing`, cancel its outstanding activation/exit work, emit -`pty.deliveryCanceled(reason='superseded')`, and return the old token in the -new response. The cancellation names the replacement token and exact remaining -old span. Main transfers that span only after matching recovery completes; -otherwise it cancels it and restores. The pair therefore has at most one -active token and no range is both canceled and replayed as new model input. A -retry served from the cache receives one fresh token, never a duplicate live -subscription. If the client becomes stale after physical commit, no token -survives; the next retry can subscribe to the retained PTY. - -## Relay output architecture - -Split policy from lifecycle and transport: - -- `pty-source-credit-ledger.ts`, `pty-source-credit-record.ts`, and - `pty-source-replay-index.ts` own cumulative token state, immutable retained - source records, exact replay ranges, rotation, and reclamation. -- `relay-pty-source-publication.ts`, - `relay-pty-source-activation.ts`, - `relay-pty-source-send-scheduler.ts`, and - `relay-pty-source-exit-publication.ts` own activation/restore settlement, - 16 Ki-su publication, recovery, exit barriers, budgets, and subscriber - scheduling. -- `dispatcher-client-writer.ts` owns the one writer per sink, activation - fences, lane queues, drain callbacks, and close settlement. - -`PtyHandler` retains PTY lifecycle, the 8 ms initial/1 ms continuation cadence, -immediate interactive/transformed publication, replay tail, streaming -transform state, and idempotent native `pause()`/`resume()`. It publishes -immutable source spans rather than broadcasting notifications. - -### Cumulative span ledger - -Each PTY ledger is append-only until every cursor has passed a span: - -```ts -type RelaySourceSpan = { - spanId: number - sourceStartSu: number - sourceEndSu: number - data: string - splittable: boolean - retainedBytes: number -} - -type PtyDelivery = { - state: 'activating' | 'recovering' | 'active' | 'sealed-unsettled' | 'closing' | 'closed' - clientId: number - clientGeneration: number - ownerGeneration?: number - deliveryToken: string - cursor: { spanId: number; displayOffset: number; sourceOffsetSu: number } - sentEndSu: number - creditedEndSu: number -} -``` - -Different subscribers may stop at different offsets in one span. A cursor -contains both display and source offsets, so no global chunk split can lose or -repeat a suffix. Data storage may coalesce adjacent splittable spans for a -frame or slice one span per cursor; the immutable source boundaries and -cumulative ends do not change. Reclamation requires every live, activating, -sealed-unsettled, closing, or reconnect-grace cursor to pass the complete -span. Native process exit seals admission but does not weaken reclamation. - -The transform publisher cuts source input into scalar-safe pieces, never -between a surrogate pair, and records each display result against its source -interval. An untransformed span may be 16 Ki su and is sliced per sink. -A transformed span is indivisible after publication, so its publisher reduces -the source slice until the encoded span is at most 8 KiB, below the token -window, and no larger than the sink's empty non-reserved capacity. An -implementation that cannot stream a transform must pause before the hard limit -and request model restore; it may not over-credit, truncate, or send an -oversized or permanently inadmissible frame. - -The existing 100 Ki-su replay-tail policy and the chunked -`RecentPtyOutputBuffer` representation from `79ec57d04` remain. -`PtySourceReplayIndex` now provides the bounded parallel source-range index -without restoring the former rolling-string re-slice. Legacy replay still -materializes the same display tail. V1 restore additionally preserves -incarnation, exact source ranges, and transform metadata. Index eviction -follows the buffer's exact retained prefix and never fabricates a mapping; the -existing append-path equivalence/performance contracts remain in force. - -### Scheduling and fairness - -The relay scheduler uses deficit round-robin over PTYs, then rotates subscribed -clients for the selected PTY. One scheduling turn admits at most: - -- two PTY frames; -- 32 Ki su; -- 2 ms of scheduler work. - -Requeue work with `setImmediate` after any limit. Input and control requests can -therefore interleave with output. A recent-input PTY may move to the front once -per round, but repeated interactive classification cannot consume another -PTY's quantum. Preserve the existing bounded interactive fast path: after any -already-queued liveness/control frame, if its token is active, has no earlier -backlog, remaining window covers the exact slice, and the writer admits it -without reserve use, publish one echo frame of at most 4 Ki su immediately -instead of waiting for the 8 ms timer or DRR. Allow at most one such frame per -input epoch; it advances the same cursor once and then yields to normal -priority selection. - -For each live/interactive candidate, all gates must be open: - -```text -frameSourceLengthSu <= windowSu - (sentEndSu - creditedEndSu) -client writer admits PTY bytes without consuming its control reserve -token state == active -``` - -For a splittable span, the admitted slice is at most -`min(16 Ki su, remainingWindowSu)`. An indivisible transformed span is admitted -only when its entire source length fits. There is no one-frame window -overshoot. Dispatcher admission creates one pending send reservation. A -`write(false)` still gives Node that frame exactly once, but the cursor and -`sentEndSu` commit only on successful writer settlement; failure rolls back -the reservation. No later ordinary frame is admitted meanwhile. Unsent frames -remain ledger-owned. Liveness, control, interactive PTY, ordinary source PTY, -and bulk are reconsidered on every writer turn. Recovery and live output use -the same source-bearing `pty.data` producer; recovery additionally requires -`token state == recovering`, exact continuity, byte capacity, and -remaining-window capacity. - -The V1 source owner and additional subscriber projections use independent -publication transactions: -`projectPtyDataToMatchingClients`/`projectPtyExitToMatchingClients` exclude -the `source-owner` and reserve each remaining client separately. If one -additional subscriber cannot reserve bounded writer/publication capacity, -close only that client and continue the healthy subscriber and source-owner -sends; never pause or tear down the native PTY solely for that projection. A -legacy-fallback primary is different: it remains a required bounded-legacy -backpressure participant and is not evicted for saturation. Constructor -position still grants no session-owner role. - -Pause when every remaining delivery is blocked or either retained-byte hard -cap is reached. Resume only when: - -```text -perPtyRetainedBytes <= 1 MiB -relayRetainedBytes <= 48 MiB -at least one delivery can advance, or no delivery remains -``` - -Both predicates are required; the per-PTY low water cannot immediately resume -while the relay-wide budget remains exceeded. Calls are idempotent because a -last `node-pty` callback may re-enter pause/exit handling. - -### Exit - -On native exit, seal the PTY's output stream after publishing the last ingress -emissions. The source-owner delivery changes to `sealed-unsettled`; additional -subscriber exits use their independent bounded projection transactions: - -1. write all preceding data within normal window and drain rules; -2. once that data is accepted by the sink, write `pty.exit` without waiting for - the final data ACK; -3. record the exit write callback as `exitPublished` only on explicit success; - on error, generation-close/cancellation proof owns cleanup. Retain the - delivery, token ledger, cursor, cumulative ACK state, and timeout while any - `[creditedEndSu, sentEndSu)` suffix remains; -4. close only when the outstanding suffix becomes terminal through cumulative - ACK application at the relay, exact transfer to a replacement, explicit - cancellation, or client-generation close proof. - -Never force a final tail past the credit window. An additional subscriber that -cannot admit its projected `pty.exit` is detached without changing the source -owner. For the session owner, cancel only this PTY token after its bounded -deadline, keep the sealed record until ACK, transfer, cancellation, or -generation-close proof, and do not tear down unrelated PTYs. A late valid ACK -against a sealed token remains valid and may complete cleanup. Sealed records -are excluded from the 50-live-native-PTY spawn admission count but remain -charged to retained-data budgets. Relay disposal generation-closes all -records and clears their scheduled work exactly once. Exit listeners and -native PTY disposal run at physical exit; logical delivery cleanup waits for -proof. - -Main has a matching receive-exit barrier. Receiving `pty.exit` seals the token -against later data but does not immediately call `runtime.onPtyExit`, retire -the headless emulator, clear desktop ranges, close remote subscriptions, or -send renderer exit. It retains every preceding model receipt, projection ID, -remote mapping, and ACK publication record. In order, it: - -1. waits for preceding model receipts; -2. publishes or exactly transfers committed desktop ranges and required - remote mappings; -3. advances/queues the terminal cumulative source ACK; -4. then invokes runtime and renderer exit cleanup while retaining ACK - publication state until success or generation-close proof. - -The barrier has the same charged 30-second deadline. At expiry, main first -sends token-scoped `pty.cancelDelivery` and waits up to 10 seconds for its -proof without applying it. If the exact barrier/generation still owns the -exit, main transfers published desktop projections to -`ssh-exit-delivery-canceled`, commits the proof to reclaim source spans, -prepares exit once, rechecks generation ownership, finalizes exit, rechecks -again, then closes the projection. This order prevents proof commit from -reclaiming a span before its published projection transfers. - -If provider-generation close wins while proof is pending, it rejects and -removes the barrier plus its preparation ownership; cancellation ownership is -operation-local rather than retained in a process-lifetime tombstone. The late -proof performs no transfer, commit, preparation, final exit, or projection -close. If cancellation publication or proof fails while the barrier remains -current, main closes the provider so generation cleanup proves cancellation; -unrelated PTYs are not torn down on the ordinary successful timeout path. A -late emulator callback cannot mutate the reset generation. Thus relay sink -ordering and main asynchronous model ordering are both proved. - -Provider generations come from one process-wide monotonic allocator and are -never reused. Main rejects closed generations through sorted, merged inclusive -ranges rather than one retained entry per reconnect. Sequential closes compact -to one range; out-of-order closes preserve exact live-generation gaps until -those generations close, then merge. This keeps stale-generation rejection -exact while retained range count follows live gaps instead of process uptime. - -## Dispatcher and transport drain - -Every dispatcher client is constructed with one bidirectional transport: - -```ts -type SinkWriteResult = 'accepted' | 'saturated' | 'closed' -type SinkWriteSettlement = { ok: true } | { ok: false; error: Error } - -type RelayClientTransport = { - write(data: Buffer, onSettled: (result: SinkWriteSettlement) => void): SinkWriteResult - writableLength(): number - writableHighWaterMark(): number - onDrain(cb: () => void): () => void - pauseReads(): void - resumeReads(): void - close(): void -} -``` - -Adapters map Node `true` to accepted, `false` to saturated, known-dead to -closed, and map the `stream.write(data, callback)` callback's optional error -to explicit success/failure settlement. Only `{ ok: true }` is a publication -fence. A callback error or thrown error closes the client, cancels the -generation, and cannot activate a token, publish ACK state, or release a source -obligation. A saturated or closed transport is never called again for ordinary -traffic before drain/replacement. -One 13-byte keepalive may bypass a saturated epoch; no second liveness bypass -is allowed until that write callback or drain, so the exemption is constant -space rather than an unbounded queue. - -`DispatcherClientWriter` is the only encoder/writer for a client. It owns: - -1. a coalesced liveness lane, at most two 13-byte frames; -2. a FIFO control lane, at most 256 frames and 1 MiB encoded; -3. producer-scheduled interactive PTY, ordinary source PTY, and bulk lanes, - whose frames remain with their producers until the writer admits them. - -It reserves -`min(64 KiB, max(1 KiB, floor(highWaterMark / 4)))` below the stream's -high-water mark for liveness/control. PTY, recovery, and bulk admission all -require -`writableLength + frameBytes <= highWaterMark - effectiveReserve`; the -scheduler reduces source slices and reshapable bulk before publish. The -`fs.streamChunk` wire contract is the exception: its fixed 256 KiB raw chunk -and sequence-offset math are preserved, and the `fixed-bulk` lane admits one -encoded frame only when the producer queue and writable sink are empty and -unsaturated. It then obeys ordinary callback/drain settlement before another -producer frame. No producer writes around these gates. -Every transport accepted for a PTY subscription must expose at least 8 KiB of -empty non-reserved capacity. A lower-capacity subscriber is rejected before -token creation, so an already-published indivisible transformed span cannot -become permanently inadmissible. -Liveness is selected first, then control FIFO, interactive PTY, ordinary source -PTY, and bulk; after one producer frame the writer re-runs selection. The -physical stream is still FIFO—V1 does not claim a second SSH channel—but the -finite burst and byte reserve bound head-of-line delay. Control overflow closes -an additional subscriber; overflow on the negotiated session owner is a -transport failure and reconnects that owner generation. - -Drain registration is one-shot per saturated epoch. `close`, `error`, -`attachClient`/`detachClient`, owner-generation replacement, and dispose -cancel every outstanding write callback and producer fence exactly once. -`setWrite` remains a test/compatibility seam; production reconnect ownership is -the socket/stdin construction plus `attachClient` and `detachClient`. - -In detached relay startup, immediately invalidate the synthetic stdout client -when `stdoutAlive` becomes false. A no-op writer must not look like successful -delivery. The first valid session-owner grant on an `attachClient` -socket/named-pipe client elects the owner; reconnect resume replaces its -generation atomically. Constructor identity and connection order confer no -role. - -One `DrainAwareStdoutWriter` owns every byte written to `process.stdout`, -including the initial relay sentinel, handshake residue, dispatcher frames, -and connect-mode forwarding. `runConnectMode` performs -`sentinel -> residue -> socket data` through that writer and pauses the source -socket after stdout saturation; it never mixes ad-hoc `stdout.write()` with -`sock.pipe(stdout)`. The same state contract wraps Unix sockets, Windows named -pipes, and the initial stdio client. - -Keepalive frames are independent of PTY credit, use reserved capacity, and have -the single-frame saturated-epoch exemption above. A decoder self-pause or -local writer-saturation epoch suspends dead-link evaluation. Resume calls the -same `rebaseHealthClocks(now)` used after a wake gap: set `lastReceivedAt` and -every existing `unackedTimestamps` entry to `now`, then allow a full timeout -window. Rebasing only received-data age is insufficient because the -outstanding-header-ACK conjunct would remain stale. During a suppressed -interval, keepalive intent coalesces behind one outstanding probe and the -header-ACK timestamp map has a hard entry cap; at 4095 entries it stops -ordinary main-to-relay admission and reserves the final coalesced entry for -cancel/liveness. -No pause fabricates an ACK. Tests sustain both states past 20 seconds and prove -zero reconnect oscillation or timestamp growth. - -## Main-process credit ownership - -`SshRelaySession.wireUpPtyEvents` remains the provider listener but stops -calling `runtime.onPtyData` and `webContents.send` itself. It validates the -provider generation/token and hands each notification once to a main-only -`SshPtyOutputDelivery` intake installed by `src/main/ipc/pty.ts`. This intake is -the only SSH owner allowed to ingest runtime output, mutate delivery state, or -send `pty:data`. - -### Main cumulative ledger - -The intake appends immutable wire spans: - -```ts -type PtySourceDeliveryIdentity = Readonly<{ - id: string - providerGeneration: number - clientGeneration: number - ownerGeneration: number - ptyIncarnation: string - deliveryToken: string -}> - -type PtySourceSpan = PtySourceDeliveryIdentity & - Readonly<{ - spanId: string - sourceStartSu: number - sourceEndSu: number - displayStart: number - displayEnd: number - data: string - splittable?: boolean - indivisible?: boolean - transform: { - transformed: boolean - rawLengthSu: number - scalarSafe: boolean - } - }> - -type SourceSpanRecord = { - span: PtySourceSpan - obligations: Map -} - -type SpanObligation = - | { state: 'open' } - | { state: 'transferring'; to: ConsumerId; reason: string } - | { state: 'settled'; reason: string } - | { state: 'transferred'; to: ConsumerId; reason: string } - | { state: 'canceled'; reason: string } - -type TokenAckPublication = { - obligationsTerminalEndSu: number - ackQueuedEndSu: number - ackPublishedEndSu: number -} - -type DesktopProjectionSpan = Readonly< - Omit & { - splittable: boolean - projectionSemanticsId: string - } -> -``` - -The ledger is separate from `PendingPtyData`. Queue merge, split, remainder, -drop sentinel, query salvage, thinning, and interactive bypass receive no -delivery token and cannot rewrite source spans. At SSH intake, -`SshPtyLegacyProjectionLedger` keeps per-PTY cursors and admits the complete -immutable `DesktopProjectionSpan`; it never receives only `data` plus source -length. -`PendingPtyData` continues to store display batching fields plus an opaque -projection admission ID, not mutable source accounting. - -`projectionSemanticsId` addresses an immutable per-admission record containing -the accepted chunk's ordered main-authoritative terminal facts and the -projection queue's before/after bounded scanner snapshots. This preserves -current main's dropped-output DEC mode 2031 subscribe/withdraw salvage without -putting mutable scanner state in the source ledger. A source gap resets the -recorded cross-chunk state before later bytes are admitted. - -When the data queue sends a display prefix, drops a pending entry, or replaces -it with salvage, it asks the range queue to consume the same operation. The -range queue—not mutable `rawLength` fields—returns the exact source length and -transform metadata for the renderer payload and consumer obligation. -Admission is transactional: - -1. reserve model bytes and create the source span; -2. stage the main terminal facts and before/after projection scanner snapshots; -3. reserve the exact desktop range by `spanId` and `projectionSemanticsId`; -4. enqueue display data and publish only after every reservation succeeds; -5. commit model ownership, main facts, and projection admission together. - -If model reservation fails, remove every uncommitted reservation. If -projection admission or `webContents.send` fails, roll back the uncommitted -range selection and restores the prior projection scanner snapshot without -publishing staged facts. After model commit, main facts remain published once -and a send failure atomically transfers the desktop obligation plus committed -projection state to a model-restore marker. Queue merge/split preserves the -ordered admission IDs, salvage/drop transfers exact superseded ranges and DEC -mode 2031 decisions, renderer reload transfers committed desktop obligations -before clearing, and token replacement rejects stale IDs by generation while -exact replacement coverage transfers their ranges. Untransformed source may -split only at a recorded display/source offset; a transformed range stays -indivisible. No rollback fabricates an ACK or destroys a live relay -obligation. Renderer ACK retirement reserves both range and ledger mutations, -validates both, then performs one no-throw commit; neither side becomes -terminal before that commit. - -Development assertions enforce: - -```text -receivedSu = openSu + transferringSu + settledSu + transferredSu + canceledSu -obligationsTerminalEndSu = - largest contiguous end whose required obligations are terminal -ackPublishedEndSu <= ackQueuedEndSu <= obligationsTerminalEndSu -``` - -Only `SshPtyOutputDelivery` may call the V1 cumulative ACK coalescer. The -generic `IPtyProvider.acknowledgeDataEvent(id, delta)` API remains for local -and daemon behavior. Legacy-fallback SSH may still emit its current delta, but -the relay ignores it and no legacy bound depends on it; it is a hard no-op for -negotiated V1 PTYs. At every shared `pty:ackData`, resync, heal, -write-off, drop, salvage, and reload call site, V1 routes renderer display -progress through -`SshPtyLegacyProjectionLedger`: parsing settles exact mapped source ranges and -heal/write-off atomically transfers them. These projection transitions may -advance ledger eligibility already earned by the model, but never emit legacy -`{ id, charCount }` wire traffic or manufacture source progress from a display -count. - -ACK eligibility and ACK publication are separate transitions. Terminal -obligations advance `obligationsTerminalEndSu` without waiting for a write -callback. The coalescer independently queues the latest cumulative eligible -end and advances `ackQueuedEndSu`. The mux write callback advances -`ackPublishedEndSu` and permits cleanup only on explicit success; it never -creates eligibility. A synchronous throw, callback error, or close leaves the -cumulative value queued for a generation-aware retry or reaches cancellation -proof on provider close. Coalescing replaces only with a greater cumulative -value, so retries cannot lose an eligible prefix. - -### Bounded asynchronous model admission - -The charged FIFO per PTY and global scheduler replace the unmetered -headless-emulator `writeChain` contract. `acceptPtyData` returns a Promise -receipt: - -1. before capturing the frame, reserve its charged retained bytes against the - per-PTY and global model-admission budgets; -2. the per-PTY budget covers one full token window and the global budget - matches the relay retained-data cap, so a conforming owner reaches token - backpressure before routine admission denial. If a transient frame still - cannot fit, keep that current decoded frame in a separately charged intake - slot and leave its model obligation open. Enter selective pressure mode: - quarantine at most 1 MiB or 64 later PTY data frames in wire order while - continuing to apply transport liveness, unrelated RPC control, and - cancellation proofs. Keep `pty.exit`, `pty.recoveryComplete`, activation - fences, and other source-ordering lifecycle frames behind all preceding - quarantined data for their token. A same-token cancellation proof may bypass - only by atomically canceling those quarantined obligations first. Do not - admit quarantined data out of order. If the reserve fills before capacity - returns, pause reads and start a 10-second provider-close deadline; -3. enqueue only after capacity is owned. Snapshot all consumer memberships and - allow bounded desktop/remote fan-out at that point; neither projection - progress nor queue ownership settles the required model obligation. - Preserve per-PTY order while allowing fair turns across PTYs; -4. resolve the receipt only from the emulator write callback. Then release the - queue charge, settle the model obligation, and advance eligible credit; -5. resume admission only after both low waters hold. Rejection cancels the - token and schedules restore; it never passes through the current - best-effort swallowed-error path. - -The relay writer rechecks control priority after every PTY frame, so the -receive reserve needs to cross only a bounded already-written PTY burst, not an -unbounded producer stream. If no control can be reached within the reserve and -deadline, provider close supplies generation cleanup proof. Selective pressure -never settles, drops, or reorders a data frame and never lets a model stall -silence cancellation indefinitely. - -Provider close, token supersession, PTY exit, and runtime disposal cancel -queued-but-unstarted entries and reject their receipts exactly once. An -in-flight emulator callback owns its entry until completion or failure and -must pass its captured token/generation check before committing. Reconnect and -supersession use the migration fence above rather than letting that callback -race a replacement checkpoint. Failure dispatch consults the exact -`(providerGeneration, ptyId)` migration owner before invoking generation close; -this does not suppress the ordinary non-migrating failure policy. The old -Promise chain may remain as the per-PTY execution primitive only after each -link is charged by this scheduler; it is no longer an unbounded owner. - -### Desktop, mobile, web, and agent policy - -Consumer membership is snapshotted when each span arrives: - -- The main terminal model is always required. - `runtime.acceptPtyDataBounded` returns the asynchronous receipt Promise - above; it resolves only after the emulator accepts the span. - Status and agent-session observers derived from that model add no duplicate - obligation. -- The desktop renderer is a recoverable projection. Parsing settles its - obligation. Hidden thinning, reload, destroyed-window, send failure, - pending-cap replacement, or delivery heal atomically transfers the - obligation to the already-accepted main model and emits a model-restore - marker before settlement. A transfer requested before the model receipt - remains pending and commits only when that receipt succeeds. Once the model - receipt exists, desktop obligations - remain tracked for main-to-renderer bounds but are not required for upstream - ACK eligibility. -- A mobile/web/raw agent terminal subscriber that negotiated lossless ACKs is - required while attached and settles independently from its bounded delivery - cursor. At its ACK cap it stops sending and remains required; a stall cannot - auto-transfer itself out of upstream backpressure. On explicit detach or - replacement, transfer to a snapshot/resubscribe marker if supported; - otherwise cancel that consumer and close only its stream. -- A legacy or observational remote subscriber is best-effort and creates no - upstream obligation. It receives bounded fan-out and is dropped/resynced on - overflow. - -Current mobile notification replay remains a separate runtime method and -identity space. Its notification epoch and post-local-delivery watermark prove -notification catch-up only; terminal obligations require the stream generation -and immutable encoded-byte/source-range mapping below. - -The token ACK advances when the main model and every currently required -lossless consumer for the contiguous prefix have terminal obligations. A -desktop stall therefore cannot freeze a healthy mobile/web consumer, while an -actually lossless remote subscriber still participates explicitly. - -Each lossless remote stream owns an immutable encoded-byte/source-range ledger: - -```ts -type LosslessRemoteSendRange = { - streamGeneration: string - encodedStartByte: number - encodedEndByte: number - providerGeneration: number - ptyIncarnation: string - deliveryToken: string - sourceRanges: ReadonlyArray<{ - spanId: string - sourceStartSu: number - sourceEndSu: number - indivisible: boolean - }> -} -``` - -Existing `terminal.multiplex` `ackOutput: 1` remains a byte-delta flow-control -mechanism and creates no SSH source obligation. A required source-mapped stream -must separately negotiate `ackOutputSourceRanges: 1`. The server allocates an -opaque `streamGeneration` in the subscribed control frame; every cumulative -ACK carries that generation and `ackedEndByte`. A reused `streamId`, old -client, legacy `{bytes}` ACK, or stale generation cannot settle the new stream. - -The mapping is recorded only when the remote writer accepts that exact encoded -output payload, in the same byte unit used by its ACK window. Batching flushes -on provider generation, PTY incarnation, or delivery-token change. When one -encoded frame contains multiple same-identity source spans, it records their -ordered composite list rather than collapsing them into a proportional range. -An ACK must be a finite safe integer in -`previousAckedEndByte <= ackedEndByte <= lastAcceptedByte`; excessive values -are rejected and counted, never clamped into full settlement. An in-range -partial-frame ACK advances the byte watermark and releases exactly that byte -budget, but retains the covering frame's immutable source mapping and settles -no source units. Only complete recorded frame boundaries settle their exact -ordered source ranges. UTF-8 width, JSON escaping, `rawLength`, and display -transforms are never converted proportionally. - -The same attached stream may cross a source-token rotation without transferring -its older unsettled frame. Admission requires the same PTY ID, provider -generation, and incarnation; strictly greater client and owner generations; a -different delivery token; and exact source/display continuity. The ledger then -rebinds future frames to the new identity while older frames retain their own -immutable identity and settle at their encoded boundaries. Equal, lower, -partially advanced, cross-provider, cross-incarnation, or same-token rotations -are rejected. Before stream replacement, snapshot recovery, or detach, create -the replacement owner and atomically transfer every remaining mapping; -otherwise cancel that consumer and its bounded stream. Old stream generations -cannot settle new mappings. - -The main registry stores the immutable source identity with each admitted -remote mapping, not only its `spanId`. A replacement reservation moves the -named consumer obligations to `transferring`; only an accepted headed or -headless `SnapshotEnd` publication at or beyond the reservation's required -sequence commits them. Stale generation, failed publication, disconnect, or -explicit rollback restores the original live-stream obligation. If -token-scoped cancellation proof has already reclaimed the authoritative span, -late ACK/settle prunes the cached mapping, detach skips absent spans, and a -replacement commit returns false without dereferencing the reclaimed record. -Rollback then deletes the exact reservation and skips already-absent spans. -These paths are idempotent and create no credit. - -ACK-pending output overflow uses the same transaction. For a source-mapped -stream, serialize the bounded snapshot, reserve only immutable spans whose -model-sequence end is covered by its `seq` through -`reserveRemoteTerminalSourceRangeReplacement`, publish every snapshot frame, -then call `commitRemoteTerminalSourceRangeReplacement` against the exact -current stream generation, snapshot source identity, and sequence. Only after -commit may the server trim queued chunks with -`chunk.seq <= snapshot.seq` and clear the overflow marker. Serialization, -publication, or commit failure calls -`rollbackRemoteTerminalSourceRangeReplacement`, preserves the live -obligations, reports the stream error, and detaches that stream. One -`ackRecoverySnapshotInFlight` flag bounds the operation to single-flight. -At the encoded-byte ledger, snapshot replacement commit is itself idempotent: -duplicate commits and commits after close are no-ops, preserve cumulative byte -coordinates, and remove only source mappings covered by the authoritative -snapshot sequence. - -The stall policy is deliberately split. Desktop-only parse failure reaches its -bounded projection cap, transfers to model restore, and lets upstream credit -continue. A stalled model receipt or attached lossless remote has no automatic -projection transfer: its required obligation stays open, the token window -exhausts, and the relay pauses the native PTY when every remaining delivery is -blocked. Only an explicit, capability-proven snapshot/resubscribe transition -can transfer a lossless remote obligation. - -### Exactly-once lifecycle - -`settle`, `beginTransfer`, and `cancel` are compare-and-set operations from -`open` for both the upstream token span and each consumer obligation. -`transferring` becomes `transferred` only after the named replacement fence or -becomes `canceled` on mismatch/failure; duplicates are no-ops with diagnostics. -Required obligations becoming terminal advances -`obligationsTerminalEndSu`. That transition makes the cumulative ACK eligible; -it does not wait for ACK publication. The coalescer advances `ackQueuedEndSu`, -and only the later mux write callback advances `ackPublishedEndSu` and allows -record reclamation. A token span begins transfer only when the relay atomically -moves reconnect-grace ownership to a new token and the attach response -declares the new cumulative boundary, or cancels after -explicit/connection-close proof. The transaction creates the replacement -owner before marking source `transferring`; only exact recovery-complete -coverage terminally transfers it. - -| Event | Mandatory transition | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| spawn/attach response decoded | install provisional `sourceActivation` in synchronous `beforeResolve`; hold same-turn data privately; spawn commits after full operation validation, while reconnect transfers into an exact private recovery quarantine after contract validation and commits after fence/admission | -| invalid-checkpoint `restoreRequired` | keep the canceled relay record through response settlement; retire only the still-current record, wake capacity, and permit a fresh-token retry | -| model accepts; desktop/mobile parses | settle that consumer | -| hidden thinning, empty transform, pending-cap salvage | preserve ordered terminal facts/scanner state, then transfer desktop to model and emit restore marker | -| renderer reload/destroy/send failure/heal | transfer all desktop obligations to model before clearing queue/accounting | -| pane closes while provider/token remains live | transfer recoverable views, then request token cancel if no required consumer remains | -| PTY exit | relay seals token; main retains prior receipts/projections/remotes; runtime/renderer exit only after terminality or bounded cancellation proof | -| provider replacement/reconnect | close old client generation; transfer only exact ranges proven by replacement recovery, otherwise cancel and restore | -| relay `pty.deliveryCanceled` | without replacement, cancel matching remainder and restore; with replacement, enter `transferring` pending coverage | -| same-client token supersession | create replacement first, transfer exact covered remainder after recovery, cancel any uncovered range | -| empty or non-empty recovery completion | require checkpoint-to-`recoveryEndSu` continuity, retain that live-start anchor after activation, then admit only an exactly contiguous live frame | -| exact exit during private recovery | wake the fence, reject later same-token admission, and publish exit only after the held prefix is admitted or exact cancellation proof retires it | -| recovery completion lacks writer capacity | retain range/fence state and retry on capacity; keep at most one admitted completion attempt | -| stale/overlapping recovery attempt | cancel only that attempt's token; never mutate the replacement mux, checkpoint, PTY ownership, physical process, or owner lease | -| current recovery cancellation publication/proof fails | after quarantine ownership transfer, close the exact provider generation and release its mux, provider, publishers, and registration once; retain physical PTY ownership for reconnect | -| partial remote cumulative byte ACK | advance/release exact byte credit; retain the covering source frame and obligation until a complete recorded boundary | -| contiguous higher-generation source token | keep the stream attached, bind later frames to the new identity, and preserve unsettled older-token frame mappings | -| remote snapshot replacement | reserve immutable span IDs/ranges, commit only after current-generation `SnapshotEnd` coverage, otherwise roll back | -| snapshot commit after proof reclaimed a span | reject commit without dereference; rollback removes the exact reservation and skips absent spans; later settle/detach prunes cached mappings | -| remote ACK-pending overflow snapshot | reserve covered spans, publish, commit exact snapshot identity/sequence, then trim; rollback and detach on failure | -| late remote detach after token cancellation | prune already-reclaimed mappings and detach idempotently without dereference or new credit | -| exit timeout receives cancellation proof | while generation-current, transfer projections, commit proof, prepare/finalize once, and close projection with generation checks between mutations | -| provider generation closes during exit proof | reject/remove barrier ownership; a late proof cannot commit, prepare, finalize, or publish exit | -| provider generations close over process lifetime | merge exact closed ranges; preserve out-of-order live gaps and reject every closed generation without one tombstone per reconnect | -| source tokens cancel repeatedly for one PTY | retain only the latest ordered canceled token for that PTY; replace at the next cancellation and clear at the next activation, PTY exit, or provider teardown | -| explicit live-token reset | `pty.cancelDelivery` response proves relay cancellation before local discard; failure closes the provider transport | -| relay/client dispose | relay cancels token/cursors; main cancels only after close-generation proof | - -There is no “abandon live token” transition. A main-side path that cannot prove -settlement, transfer, or relay cancellation must keep the ledger or close the -provider. Delayed renderer/mobile ACKs carry the consumer generation and cannot -settle a new provider generation or reused PTY ID. - -### State-machine pseudocode - -```text -session owner: - none --authenticated claim--> electing(next monotonic generation) - electing --session-grant write success--> active - electing --session-grant write error--> none(close generation) - electing --client close/cancel--> none - active --proved live replacement/tokens to grace--> - active(generation + 1, rotated lease) - active --unexpected client close--> grace - grace --proved resume--> active(generation + 1, rotated lease) - grace --30-second expiry--> none(invalidate lease, cancel grace cursors) - -relay token: - create -> activating - activating --response write success/no recovery--> active - activating --response write success/recovery--> recovering - activating --response write error--> closed(cancel generation) - recovering --completion not admitted--> recovering(retry on capacity) - recovering --completion admitted--> completion-in-flight(single) - completion-in-flight --write success--> active - completion-in-flight --write error--> recovering(exact replacement/cleanup) - recovering --invalid checkpoint--> restore-canceled-awaiting-response - restore-canceled-awaiting-response --exact response settles/current record--> - closed(delete record, wake capacity) - activating --stale/close--> closed(cancel) - activating|recovering|active --same-client replacement--> closing(superseded) - active --native exit--> sealed-unsettled - sealed-unsettled --exit write success--> sealed-unsettled(exit-published) - sealed-unsettled --exit write error--> closing(generation proof) - sealed-unsettled --relay applies suffix ACK/exact transfer--> closed - sealed-unsettled --timeout--> closing(exit-timeout cancellation proof) - active --cancel request--> closing - closing --cancel response/client close proof--> closed - -writer: - writable --write(false)--> saturated --drain--> writable - writable|saturated --close/error/detach--> closed - -main mux writer selection: - any queued liveness --> select liveness - control queued and (ordinary empty or controlsSinceOrdinary < 4) --> select control - ordinary queued --> select ordinary(reset controlsSinceOrdinary) - control queued after ordinary empty --> select control - drain --> writable(re-run the same selection; preserve FIFO inside each lane) - -main obligation: - open --consumer receipt--> settled - open --replacement created atomically--> transferring - transferring --exact replacement coverage fence--> transferred - transferring --missing coverage/replacement close--> canceled - open --relay cancel/close proof--> canceled - -main token span: - open --required consumers terminal--> obligations-terminal - obligations-terminal --coalescer queues cumulative end--> ack-queued - ack-queued --ACK write success--> ack-published(cleanup eligible) - ack-queued --ACK write error--> ack-queued(close/retry) - open|obligations-terminal|ack-queued - --attach boundary + replacement created--> transferring - transferring --exact recovery-complete fence--> transferred - transferring --gap/replacement failure--> canceled - open|obligations-terminal|ack-queued - --cancel response/client-generation close--> canceled - -main receive token: - unseen --beforeResolve--> provisional(cursor = checkpointSourceEndSu, heldData = []) - provisional --same-turn contiguous data--> provisional(append heldData; no projection) - provisional --all spawn/non-recovery attach/exit validation--> - active(commit lease, publish heldData in order) - provisional --validated recovery contract + private quarantine installed--> - receiving-activation(transfer lease to private sink, route heldData by range) - provisional --pre-transfer validation failure--> - rollback(drop heldData, restore active predecessor, request exact cancellation) - rollback --valid cancellation response--> prior exact state - rollback --publication/proof rejects--> fail operation(execution owner unavailable) - receiving-activation --quarantine exact checkpoint..recoveryEnd + complete fence--> - active(final commit, expectedLiveStart = recoveryEnd) - receiving-activation --exact exit--> - exit-sealed(admit held contiguous prefix or prove exact cancellation first) - active --first/next live starts at expectedLiveStart--> active(advance expectedLiveStart) - receiving-activation|active --gap/overlap/bad fence--> canceling-only-this-token - active --pty.exit--> exit-sealed - exit-sealed --prior model/projection/remote terminal--> exit-ack-queued - exit-ack-queued --runtime + renderer exit cleanup--> exited-awaiting-ack-publication - exited-awaiting-ack-publication --ACK success/generation close--> closed - exit-sealed --deadline--> acquiring-exit-cancellation-proof - acquiring-exit-cancellation-proof --proof + generation current--> - transfer-projection -> commit-proof -> prepare-once -> finalize -> close-projection - acquiring-exit-cancellation-proof --generation closes--> - closed(reject barrier; late proof has no authority) - acquiring-exit-cancellation-proof --proof cannot publish while current--> closing-provider - receiving-activation|active --overflow/cancel/close--> closed - -main provider-generation guard: - open(g) --close--> closed-ranges(add [g, g], merge adjacent/overlapping) - event(g in closed-ranges) --> reject(stale generation) - event(g in an unclosed gap) --> validate PTY incarnation normally - diagnostics --> closed range count + allocated-unclosed IDs below high-water - -main canceled-source retirement: - none(pty) --cancel token T--> latest(pty, T) - latest(pty, T) --later ordered cancel U--> latest(pty, U) - latest(pty, T) --frame T--> drop - latest(pty, T) --next activation/PTY exit/provider teardown--> none(pty) - -remote source ledger: - frame-open --partial cumulative byte ACK--> - frame-open(release exact byte delta; retain source mapping) - frame-open --ACK reaches encoded frame end--> frame-settled(settle exact source ranges) - old-token frame-open --contiguous strictly higher client+owner generation/new token--> - old-token frame-open + new-token receiving - receiving --stale/partial generation advance or identity discontinuity--> reject - -remote projection replacement: - live --reserve immutable spans + required SnapshotEnd seq--> transferring - transferring --current generation SnapshotEnd covers required seq--> transferred - transferring --stale/failure/disconnect--> live(rollback) - transferring --authoritative span already reclaimed--> - commit-rejected -> rollback(skip absent span, delete reservation) - live|transferring --authoritative token already canceled--> detached(prune cached spans) - -remote ACK-overflow recovery: - overflowed --reserve snapshot-covered spans--> transferring(single-flight) - transferring --snapshot publish + exact commit--> live(trim through snapshot seq) - transferring --serialize/publish/commit failure--> detached(rollback first) -``` - -```ts -function reserveSshAdmissionAtomically( - token: DeliveryToken, - frame: TokenizedPtyData -): SshAdmissionReservation { - const model = runtime.reservePtyData(frame) - let span - let projection - try { - span = token.ledger.reserveContiguous(frame, model) - span.require('model') - addConsumerObligationsFromCurrentPolicy(span) - projection = projectionLedger.reserve(toDesktopProjectionSpan(span)) - return { model, span, projection } - } catch (error) { - projectionLedger.rollbackIfReserved(projection) - token.ledger.rollbackIfUncommitted(span) - runtime.rollbackPtyData(model) - throw error - } -} - -function acceptSshFrame(frame: TokenizedPtyData): void { - const token = validateActiveProviderToken(frame) - let reservation - try { - reservation = reserveSshAdmissionAtomically(token, frame) - } catch (error) { - cancelTokenAfterAdmissionFailure(token, error) - return - } - const { span, modelReceipt, projectionSpan } = commitSshAdmission(reservation) - try { - projectionLedger.publish(projectionSpan, frame.data) - } catch (error) { - transferDesktopToModelRestore(span, 'renderer-send-failed', error) - } - fanOutToCurrentRemoteConsumers(span) - void modelReceipt.then( - (receipt) => { - span.settle('model', receipt) - commitPendingProjectionTransfers(span) - advanceObligationsTerminalEnd(token) - queueCumulativeAck(token) - }, - (error) => cancelTokenAfterModelFailure(token, span, error) - ) -} - -function retireDesktopDisplayPrefix(id: string, processedDisplayChars: number): void { - const selection = projectionLedger.reserveDisplayPrefix(id, processedDisplayChars) - const transaction = ledger.reserveDesktopSettlement(selection.ranges) - try { - validateDesktopSettlement(selection, transaction) - commitDesktopRangeAndLedgerAtomically(selection, transaction, 'renderer-parse') - } catch (error) { - ledger.rollbackDesktopSettlement(transaction) - projectionLedger.rollback(selection) - throw error - } - advanceObligationsTerminalEndForPty(id) - queueCumulativeAckForPty(id) -} - -function replaceDesktopProjection(id: string, reason: string): void { - for (const obligation of ledger.openDesktopObligations(id)) { - obligation.beginTransferTo('model-snapshot', reason) - } - scheduleModelRestoreAfterReceipts(id, reason) - commitTransfersWhoseModelReceiptsExist(id) - advanceObligationsTerminalEndForPty(id) // pending transfers remain ineligible - queueCumulativeAckForPty(id) -} - -function onAckWriteSettled(token: DeliveryToken, endSu: number, result: SinkWriteSettlement): void { - if (!result.ok) { - closeProviderAfterAckWriteError(token, result.error) - return - } - token.ackPublishedEndSu = Math.max(token.ackPublishedEndSu, endSu) - reclaimPublishedPrefix(token) - maybeCloseSealedDelivery(token) -} - -function onRelaySourceAck(record: RelayDelivery, ack: SourceAck): void { - requireExactDeliveryIdentity(record.identity, ack) - const pendingEnd = record.pendingSend?.span.sourceEndSu - if (ack.creditedEndSu > record.sentEndSu) { - require(ack.creditedEndSu === pendingEnd) - record.reservedAckEndSu = pendingEnd - return - } - applyCommittedBoundaryAck(record, ack) -} - -function retryRelaySourceSend(record: RelayDelivery): SendReservation | null { - const requiredEnd = record.reservedAckEndSu - return requiredEnd === null - ? reserveNextWindowSlice(record) - : reserveExactSameTokenBoundary(record, requiredEnd) -} - -function finishRecovery(candidate: RecoveryCandidate, fence: RecoveryComplete): void { - const expectedStart = candidate.checkpointSourceEndSu - const recoveredEnd = validateExactContiguousQuarantine(candidate.frames, expectedStart) - if (recoveredEnd !== fence.recoveryEndSu) { - cancelReplacementTokenOnly(candidate.identity, 'recovery-coverage-mismatch') - return - } - commitQuarantinedRecovery(candidate) - installLiveStartAnchor(candidate.identity, fence.recoveryEndSu) -} - -async function rollbackProvisionalActivation(lease: ReceiveLease): Promise { - lease.dropHeldDataAndRestoreActivePredecessor() - const proved = await cancelExactTokenThroughOriginatingMux(lease.identity) - if (!proved) throw executionOwnerUnavailable() -} - -function acknowledgeRemoteBytes(stream: RemoteStream, ackedEndByte: number): void { - const delta = stream.validateAndAdvanceCumulativeByteAck(ackedEndByte) - stream.byteBudget.release(delta) - for (const frame of stream.completeFramesThrough(ackedEndByte)) { - settleExactSourceRanges(frame.sourceRanges) - } -} - -function replaceRemoteConsumer(stream: RemoteStream, requiredSeq: number): void { - const reservation = remoteRanges.reserveImmutableSpans(stream, requiredSeq) - publishSnapshotEnd(stream).then( - (publication) => { - if (!remoteRanges.commitIfCurrentAndCovered(reservation, publication)) { - remoteRanges.rollbackSkippingReclaimed(reservation) - } - }, - (error) => remoteRanges.rollback(reservation, error) - ) -} - -async function cancelTimedOutExit(exit: ExitBarrier): Promise { - const proof = await requestCancellationProofWithoutMutation(exit.identity) - if (!exit.isCurrentGeneration()) return - transferPublishedProjection(exit.identity) - commitCancellationProof(proof) - prepareExitOnce(exit.identity) - if (!exit.isCurrentGeneration()) return - finalizeExit(exit.identity) - if (!exit.isCurrentGeneration()) return - closeProjection(exit.identity) -} - -async function recoverAckOverflow(stream: RemoteStream): Promise { - const snapshot = await serializeBudgetedSnapshot(stream) - const reservation = reserveCoveredRemoteSpans(stream, snapshot.seq) - try { - require(publishSnapshotFrames(stream, snapshot).published) - require(commitExactSnapshotReplacement(reservation, snapshot.source, snapshot.seq)) - trimQueuedOutputThrough(stream, snapshot.seq) - } catch (error) { - rollbackSnapshotReplacement(reservation, error) - detachRemoteStream(stream) - } -} -``` - -Token/span validation, charged admission, and every membership obligation occur -before asynchronous model execution. Desktop/remote fan-out may begin after -the admission budget owns the frame, but the model obligation settles only -after the emulator callback. Admission wait leaves it open and pauses reads at -the charged limit; rejection triggers token cancel rather than acknowledging -data the model did not own. Renderer progress is always display-side input to -the range queue and is never interpreted directly as source units. - -## Incremental frame decoding - -The actual main transport contract is extended as follows: - -```ts -type MultiplexerTransport = { - write(data: Buffer, onSettled: (result: SinkWriteSettlement) => void): SinkWriteResult - writableLength(): number - writableHighWaterMark(): number - onWriteDrain(cb: () => void): () => void - onData(cb: (data: Buffer) => void): () => void - onClose(cb: () => void): () => void - pauseReads(): void - resumeReads(): void - close(): void -} -``` - -`waitForSentinel` constructs these hooks from the real `ClientChannel`: -`channel.stdin.write(buf, callback)`, writable length/high-water mark, and -`on('drain')` for output, plus -`channel.pause()/resume()` for relay stdout. This covers ssh2 and -`SystemSshCommandChannel`, whose stdout-facing channel implements the same -Readable contract. WSL child stdin/stdout exposes the same metrics, -callback/drain, and pause/resume adapter. The startup residue is delivered once -before later data. - -`SshChannelMultiplexer` also owns one main-to-relay writer with exactly three -bounded FIFO lanes: - -1. `liveness` for the coalesced probe, including the one saturated-epoch - bypass; -2. `control` for source ACK, cancellation, exit, RPC request, and response - frames; -3. `ordinary` for `pty.data`, including PTY input/paste traffic. - -The scheduler rechecks priority after every frame and whenever drain reopens -the writer. Liveness wins first. Control wins over queued ordinary traffic -until four control frames have been selected while ordinary remains queued; -the next selection must be one ordinary frame and resets the counter. FIFO is -preserved within each lane. All non-bypass writes still wait for their -per-write callback and `onWriteDrain` after saturation. Producer-side bulk -shaping and fixed filesystem-frame admission remain with their existing -owners; they do not create a fourth mux-writer lane. Thus control cannot remain -behind an old ordinary backlog after drain, ordinary input cannot starve under -a sustained control stream, and V1 ACK settlement has a concrete write fence. - -On the relay, the initial client uses `process.stdin.pause()/resume()` and each -accepted Unix-socket/named-pipe client uses `socket.pause()/resume()`. -`attachClient` accepts those hooks; `detachClient` releases them. Handshake -decoders retain synchronous first-frame behavior and transfer both exact -residue and pause ownership to the dispatcher. - -Both protocol copies then use this state machine: - -1. `feed()` appends buffer views without copying and charges bytes. -2. Drain at most 64 frames or 4 ms. -3. If another complete frame remains, acquire one idempotent decoder pause - epoch and continue with `setImmediate`. -4. Release the pause only when complete-frame backlog and queued bytes fall - below low water. -5. `reset`, handshake `drain`, transport close, and dispose cancel the - continuation and release exactly the epoch they own. - -Main model-pressure mode is the one bounded exception to immediate read pause. -It may classify complete frames into the charged 1 MiB/64-frame data -quarantine while dispatching only eligible control/liveness frames, then -either drains data and token-ordering fences in original order or closes the -provider at the deadline. Exit, recovery completion, and activation cannot -overtake a preceding quarantined frame for the same token. It does not scan -past an incomplete or oversized frame and cannot expand the ordinary decoder -input cap. - -Handshake consumers require the first complete frame synchronously. Preserve -that behavior, then yield before later frames. `drain()` must cancel a scheduled -continuation and return all unread handshake residue in exact byte order so the -next consumer receives it once. - -The decoder permits one advertised valid 16 MiB frame plus 1 MiB input slack. -A partial valid frame keeps reads enabled until that cap; exceeding it closes -the offending transport rather than retaining back-to-back maximum frames. -Oversized payloads are discarded incrementally without full retention. -Sender-side finite PTY bursts and the reserved control lane bound how far a -keepalive/control frame can sit behind PTY data. While paused by this decoder, -the mux suspends both dead-link conjuncts. Resume rebases `lastReceivedAt` and -all outstanding header-ACK timestamps exactly like the existing wake-gap -handler; outgoing keepalives still run. - -In `SshChannelMultiplexer.handleFrame`, clamp header `ack` to -`nextOutgoingSeq - 1` and delete only keys already present in -`unackedTimestamps`. Never iterate a dense integer range toward an untrusted -32-bit value. This remains independent from PTY credit. - -## Cleanup and reconnect - -Every dispatcher client has a generation, and the negotiated session owner has -a separate transferable owner generation. Socket close/error, -`attachClient`/`detachClient`, and dispose invalidate or advance the client -generation atomically. Unexpected disconnect retains the owner generation and -lease through grace; only a proved replacement increments it, and grace expiry -invalidates the lease so a later election starts a new generation. Each -transition: - -- cancel read-pause epochs, writer callbacks, and scheduled work; -- reject queued liveness, control, and ordinary mux frames, clear lane heads, - and reset the four-control fairness counter; -- roll back uncommitted receive-activation leases to their exact prior - active predecessor, drop lease-private data, and await cancellation of only - the provisional token through its originating mux; -- after a validated recovery contract transfers the lease, retain held frames - only in that attempt's bounded quarantine and let cancellation proof or - provider-generation close own failure cleanup; -- on exact exit, seal the private lease, wake its fence, and either admit the - held contiguous prefix or validate cancellation against the highest exact - frame observed before retention/restore decisions; -- fail closed on the exact provider generation when a current recovery cannot - publish cancellation or validate its proof; provisional spawn rollback - failure instead surfaces execution ownership as unavailable; -- retain canceled `restoreRequired` relay deliveries until their response - settlement callback, then delete only the exact still-current record; -- clear or transfer one recovery-completion attempt and its capacity listener; -- move active PTY cursors to reconnect grace or cancel them; -- preserve sealed-unsettled suffixes until the relay applies their ACK, exact - transfer, or cancellation/generation-close proof; -- retry or cancel queued cumulative ACK state without collapsing - `obligationsTerminalEndSu`, `ackQueuedEndSu`, and `ackPublishedEndSu`; -- retain or cancel an exact early-ACK send boundary with its owning token; -- roll back uncommitted desktop reservations and transfer committed - projection IDs before clearing renderer queues; -- cancel uncommitted terminal-fact publications, restore their prior scanner - snapshots, and transfer committed projection scanner state exactly once; -- roll back any remote snapshot replacement before clearing ACK-overflow state; -- preserve partial remote byte watermarks while retaining covering source - frames, and prune cached mappings whose authoritative spans were reclaimed; -- reject and remove exit barriers plus cancellation/preparation ownership on - provider-generation close so late proofs cannot publish final exit; -- close each per-generation model-migration gate after its settlement, remove - its per-PTY fence and timer exactly once, and retain any earlier outstanding - per-PTY fence across an overlapping reconnect; -- on migration-owned callback failure, release only that PTY's admission - charge, timer, and tracked completion before reset/restore; do not close the - shared provider or sibling admissions; -- replace the latest canceled-token record per PTY on ordered cancellation and - clear it on exact next activation, PTY exit, or provider teardown; -- release shared spans no longer referenced; -- recompute native PTY pause state; -- expire pending RPC ownership. - -Unexpected loss of the session-owner generation with active PTY tokens creates -one `reconnect-grace` cursor per PTY for 30 seconds. It retains at most -512 Ki su and 2 MiB charged bytes per PTY; reaching either cap pauses that PTY -instead of dropping more output. A valid owner resume atomically installs the -new owner generation, transfers each cursor to a new token, and returns only -activation and recovery-range metadata. Source-ranged `pty.data` then drains -before the completion fence and live activation. Expiry invalidates the owner -lease, -emits `pty.deliveryCanceled` when possible, releases the gap, resumes the PTY, -and records restore-required/data-gap telemetry. Ordinary subscriber loss or -no-subscriber state outside owner grace retains only the existing replay tail. - -`SshRelaySession.reattachKnownPtys` waits for session-grant readiness and each -PTY's model-migration fence, then runs at most eight attaches concurrently. -Each PTY has its own 10-second attempt deadline and `try/catch`; one failure -cannot abort later PTYs. `notFound` and identity mismatch keep their existing -stale-lease behavior. Other errors are recorded, retried once with bounded -jitter while the same reconnect attempt is current, then surfaced per PTY -without tearing down successful siblings. Time-to-last-reattach is therefore -bounded by waves rather than `N × RTT`. - -Reconnect and replay always create new tokens even when IDs are reused. Old -ACKs and callbacks fail generation/token checks. The attach response declares -the exact checkpoint and recovery end; ordinary source-bearing `pty.data` -proves the gap and the completion fence establishes the live boundary, so main -never double-ingests old obligations. Replace -`SshRelaySession.forwardReattachReplay` with this tokenized intake and delete -`RECONNECT_REPLAY_DUPLICATE_WINDOW_MS`/`shouldForwardReattachReplay`: a -wall-clock fingerprint can suppress legitimate identical output, while the -source checkpoint is authoritative. Exit before reattach may still synthesize -`code: -1` after `notFound`; retaining remote exit tombstones is a separate -behavior change and is not required for the memory bound. - -Recovery receipt is transactional. Main retains quarantined recovery `pty.data`, -the candidate mux, token, checkpoint, and `recoveryEndSu` until the exact -completion fence validates. It commits no partial body to the model. On -failure it requests cancellation only through the candidate mux and accepts -only matching proof; a stale attempt may not touch current mux state. A -quarantine-only attempt may not yet have a main-intake token identity, so -`SshRelaySession` first requires `canceled: true`, finite safe cumulative -ends, `creditedEndSu` equal to the recovery checkpoint, and `sentEndSu` -covering the highest exact-token source range observed privately. Only then is -the intake's no-identity application an intentional no-op; malformed, -under-covering, or checkpoint-mismatched proof fails closed. A -current attempt whose cancellation publication or proof rejects closes the -exact provider generation and its registered resources once, rather than -leaving ambiguous authority. After a successful empty recovery it keeps -`recoveryEndSu` as a live continuity anchor until the first live frame arrives. -A proved recovery failure marks the existing PTY lease detached and retryable -rather than deleting ownership or terminating the physical process. - -For an operation-ID spawn replay, physical result lookup completes first; the -current request then creates and fences a new subscription as specified above. -Disconnect after physical commit/before response leaves the PTY retained but -no stale token. - -PTY shutdown and natural exit cancel native pause state before disposing -node-pty. Natural exit may still leave a charged sealed-unsettled delivery -record; only its native process is gone. Relay disposal generation-closes those -records and clears scheduler/exit timers before walking PTYs. A folder -workspace removal follows the same PTY teardown path; no design step assumes a -Git worktree. - -### SSH producer-pause intent - -Negotiated V1 implements `SshPtyProvider.pauseProducer` and `resumeProducer` -with a token/generation-scoped `pty.setDeliveryPaused` request; window size is -not renegotiated to zero. Relay marks that owner delivery ineligible and pauses -the native PTY only when all required deliveries are ineligible, while another -healthy subscriber may continue. Resume restores eligibility under the -original window. `setPtyBackgrounded` is a separate token-scoped scheduling -hint; it does not itself drop source data or silently convert a lossless model -obligation into keep-tail behavior. - -For V1, desktop pending-cap pressure transfers the recoverable projection and -does not call producer pause. Model-admission or required-lossless pressure and -explicit background policy may call it. Local, daemon, and legacy provider -behavior remains unchanged. Update the provider-interface and IPC comments -that currently assume SSH has an independently bounded pending queue. - -## Budgets - -Every limit has one unit. Source-flow limits use `su`; heap/transport limits use -bytes measured at the point that owns the memory. - -| Resource | High limit | Low/flush point | Action | -| ------------------------------- | -----------------------------------------: | ------------------------: | ------------------------------------------- | -| Legacy publications per client | 2 MiB | 1 MiB | pause affected PTYs; release on drain | -| Legacy publications per relay | 32 MiB | 24 MiB | pause affected PTYs | -| Legacy producer-held PTY frame | 128 KiB | next admission | hold one; pause before another | -| V1 data frame | 16 Ki su target | — | scalar-safe slice/coalesce | -| Encoded PTY frame | 128 KiB | — | reduce source slice before publish | -| Token outstanding credit | 256 Ki su | 64 Ki su newly freed | stop send / eager ACK | -| Sealed-unsettled suffix | token window; 30 s timer per subscriber | ACK/transfer/cancel proof | retain ledger; cancel stalled token | -| Recovery `pty.data` | 16 Ki su target | next turn | normal source lane before completion | -| Reconnect-grace source | 512 Ki su per PTY | — | pause at cap | -| Retained live data per PTY | 2 MiB | 1 MiB | pause source owner | -| Retained live data per relay | 64 MiB | 48 MiB | pause affected source owners | -| Replay tail | existing 100 Ki su × 50 PTYs | — | source-range trim; bytes charged globally | -| Liveness/control writer reserve | 25% of high water, capped at 64 KiB | drain | producer lanes cannot consume | -| Minimum PTY sink capacity | 8 KiB non-reserved empty | — | reject PTY subscription | -| Metadata control response | 64 KiB | — | body must use producer stream | -| Control queue | 256 frames / 1 MiB | drain | close subscriber; owner reconnect | -| Reshapable bulk producer frame | current non-reserved sink capacity | next frame | slice, admit, recheck priority | -| Fixed filesystem frame | existing 256 KiB raw payload | empty producer/sink epoch | admit one unchanged, then await settlement | -| Decoder | 16 MiB frame + 1 MiB slack | no complete backlog | pause/close at hard cap | -| Decoder turn | 64 frames or 4 ms | next turn | `setImmediate` | -| Header-ACK timestamp entries | 4095 + 1 reserved | ACK/resume | stop ordinary writes; coalesce liveness | -| Main receive-activation hold | 256 Ki su / 2 MiB per token; 64 MiB total | recovery completes | pause aggregate; close provider on cap | -| Main model admission per PTY | 256 Ki su / 2 MiB | 128 Ki su / 1 MiB | pause mux admission | -| Main model admission global | 12.5 Mi su / 64 MiB | 8 Mi su / 48 MiB | pause mux admission | -| Main blocked-intake slot | one 128 KiB frame | model low water | hold blocked frame | -| Main pressure control reserve | 1 MiB / 64 frames | 10-second close deadline | quarantine data; service control | -| Main model migration fence | 10 seconds | — | reset generation / restore required | -| Main desktop in-flight | existing 512 Ki su per PTY / 8 Mi su total | existing lows | transfer/restore policy | -| Lossless remote send ledger | 2 MiB/stream, 16 MiB global | 1 MiB/12 MiB bytes | stop send; explicit detach transfers/closes | -| Activation/exit/owner grace | 30 seconds | — | token cancel/gap policy | - -Retained strings are charged once as -`max(Buffer.byteLength(value, 'utf8'), 2 * value.length) + 128`; encoded buffers -use exact `Buffer.byteLength`; ledger/cursor entries charge 128 bytes. Shared -strings are not multiplied by subscriber count, but each cursor is charged. -Sent-but-uncredited source units are not described as retained memory. - -The table is arithmetically reachable: - -- 256 Ki ASCII su charges at most about 512 KiB plus records. -- 256 Ki BMP/CJK su at three UTF-8 bytes each charges about 768 KiB. -- 256 Ki su of surrogate-pair characters has 128 Ki scalar values and about - 512 KiB UTF-8/UTF-16 storage. -- A 512 Ki-su ordinary reconnect backlog is at most about 1.5 MiB for valid JS - strings; 32 16-Ki-su span records keep it below the 2 MiB per-PTY cap. - Highly fragmented transformed records bind the charged 2 MiB cap earlier. - Recovery is streamed and never compared with the 1 MiB control queue. -- JSON control-character escaping can reach six encoded bytes per su, so a - 16 Ki-su frame is below 96 KiB plus envelope and the separate 128 KiB cap. -- A transformed span with `rawLength !== data.length` consumes source credit - by `rawLength` and heap/wire budgets by its actual retained/encoded bytes; - neither value is converted into the other. -- Fifty PTYs cannot each reach 2 MiB because the 64 MiB relay cap binds first; - the independent 48 MiB global low water prevents immediate resume thrash. -- Model admission charges captured strings and queue records before a Promise - link owns them. Its 64 MiB global high water matches the relay retained-data - cap and its 12.5 Mi-su high equals 50 token windows, while each - 256 Ki-su/2 MiB per-PTY high covers one full token window; the separate - 128 KiB blocked slot covers the decoder frame that triggered pause without - hiding it in the model budget. -- Encoded lossless-remote bytes remain charged until full frame-boundary ACK or - atomic transfer; the ledger's source units are never estimated from bytes. - -One re-entrant native callback after `pause()` is a charged transient overshoot. -The callback may cross a retained-memory high cap by at most one charged native -chunk, but it is never admitted past token credit: splittable source is trimmed -to remaining window and an indivisible transform waits. If retained memory -crosses a hard cap, stop publication, mark restore-required, and retain only -the already-owned bounded state. Module constants allow test overrides; Linux -Docker heap/RSS plateaus validate allocator slack before release. - -## Telemetry - -Expose aggregate counters in existing relay/main diagnostic snapshots without -logging terminal contents or raw PTY IDs: - -- active/activating/recovering/closing tokens, owner generation/election/resume - outcomes, and PTY-subscribing clients; -- paused and reconnect-grace PTYs, grace bytes, recovery/fence latency, exact - gap ranges, completion retries, expiries, and gap outcomes; -- client writer state, lane depths, reserved-byte denials, and activation-fence - latency/cancellation, split bulk bytes, and per-priority wait; -- current/peak retained bytes and outstanding su by redacted client/PTY; -- pause count and total paused milliseconds; -- sink saturation count and duration by stdout/socket; -- slow-client detach and control-overflow counts; -- ACK accepted, duplicate, regression, over-credit, malformed, and stale-token - counts, early-boundary reservations/retries, plus ACK frames/second, - entries/frame, and encoded bytes/second; -- span obligations opened, transferring, settled, transferred, canceled, - duplicate-terminal, and oldest-open/oldest-transferring age by consumer - class; -- per-token obligations-terminal, ACK-queued, and ACK-published ends plus their - deltas; sealed-unsettled count, suffix size/age, exit-published state, and - timeout cancellation proof; -- desktop projection reservations, commits, rollbacks, model transfers, stale - identity rejects, and outstanding source/display ranges; -- model-admission current/peak bytes per PTY/global, blocked duration, - emulator completion/failure/cancellation, migration-fence latency/timeout, - late-generation rejects, and low-water resumes; -- lossless-remote encoded bytes, partial/frame-boundary ACKs, mapped source - ranges, generation rejects, ACK-overflow snapshot reserve/publish/commit/ - rollback, and transfer/cancel outcomes; -- decoder queued bytes, yielded turns, maximum frames/turn, and maximum - callback duration, read-pause duration, liveness-timeout suppression, - header-ACK timestamp depth/cap denials, and keepalive coalescing; -- exit-barrier timeout count; -- closed provider-generation range count and active gaps; -- reconnect attach wave latency, per-PTY retry/failure, token supersession, - relay-initiated cancellation proof, and time-to-last-success; -- same-build V1 grants, capability omissions from legacy clients, - method-not-found fallbacks, unsupported manual version skew, and discovered - orphan prior-version daemons, keyed by relay build/version rather than - legacy ACK traffic. - -Rate-limit warnings by connection and reason. Log thresholds and state -transitions, never data. Add an E2E-only snapshot request so tests can assert -plateaus, obligation conservation, pause/resume, and zero false reconnects -without parsing logs. - -## Incremental implementation map - -This PR implemented the design as the independently testable layers below, -without replacing local/daemon protocols or claiming live evidence for every -deployment topology. The slices remain useful review boundaries even though -they ship in one PR. - -### Slice 1: hostile-header and accounting hardening — implemented - -- In `src/main/ssh/ssh-channel-multiplexer.ts`, clamp header ACKs and delete - only present timestamp keys; cap retained timestamps. -- Add focused unit tests for `ack=0xffffffff`, concurrent ACK/timeout cleanup, - and liveness rebasing. -- No relay, daemon, runtime, or provider protocol change. - -### Slice 2: bounded decoder turns — implemented - -- In both relay protocol decoders, bound frames and time per turn, preserve a - synchronous first handshake frame, pause reads during continuation, and - transfer exact residue on reset/drain. -- Adapt ssh2, system SSH, WSL child stdio, relay stdin, Unix sockets, and - Windows named pipes behind their existing transport constructors. -- No PTY credit or session negotiation; prior-version peers keep the same - frame format. - -### Slice 3: one drain-aware legacy writer — implemented - -- Add a concretely named dispatcher writer owning ordinary, bulk, control, - sentinel, residue, and `--connect` bridge bytes. -- Route existing `RelayDispatcher.notify`, `notifyBulk`, fs/Git producers, and - `runConnectMode` through its bounded lane admission; stop after - `write(false)` until callback/drain. -- Preserve fixed `fs.streamChunk` size/offset compatibility in an exclusive - empty-sink `fixed-bulk` lane; slice only reshapable bulk and PTY output. -- Because legacy `notify` is synchronous and broadcast, add a bounded - transport-only publication record containing one encoded frame and the set - of client generations that have not accepted it. A client returning - `write(false)` leaves that set because Node accepted the frame; a saturated - client is retried only for that client, and close cancels its membership. - This is delivery bookkeeping, not source credit. -- Return producer admission to `PtyHandler`. Until a publication is admitted, - retain it in the existing PTY pending-output owner; at its hard byte cap - pause node-pty, then resume below low water. Cap per-client and aggregate - publication bytes. Before Slice 5a establishes authenticated roles, treat - every subscribed connection equally: any retained subscriber may pause that - PTY, and only transport close cancels its membership. Do not infer or detach - an “additional” client by constructor or connection order. Authenticated - slow-subscriber eviction begins only after Slice 5a. Never silently drop a - legacy PTY frame. -- Preserve the current 8 ms/1 ms PTY cadence and current replay semantics. The - replay tail stays the chunked `RecentPtyOutputBuffer` added in - `79ec57d04`; `PtySourceReplayIndex` now indexes it without replacing it with - the former per-append rolling string. -- This slice bounds relay writable queues for legacy main, WSL, direct SSH, - and detached relay socket/named-pipe clients without changing the wire. The - separate local terminal daemon is not exercised or changed. - -### Slice 4: bounded main SSH intake — implemented - -- Make `SshRelaySession.wireUpPtyEvents` hand each event exactly once to a - main-only intake under `src/main/ipc/`; SSH no longer bypasses the existing - pending-output authority. -- Meter model admission and its emulator callback chain, reserve before - capture, pause decoder reads at the cap, and return completion/failure - receipts. -- Introduce transactional projection admission with a legacy immutable - admission ID, provider generation, PTY incarnation, display interval, and - existing sequence/`rawLength`; it has no V1 token or source-credit - coordinate. Slice 5b extends that record to `DesktopProjectionSpan`. Keep - local and daemon providers on their existing intake until a shared migration - is separately justified. -- Preserve current main's ordered terminal side-effect facts and dropped-output - DEC mode 2031 scanner snapshots across admission, salvage, rollback, transfer, - and explicit gaps. Preserve `terminal-stream-byte-length.ts` UTF-8 accounting - and flush boundaries for remote streams; never reuse it as source credit. -- Extend the existing Docker ACK-stall test only for behavior this slice - implements: direct SSH/deployed relay/desktop intake and active typing. - -### Slice 5a: SSH session semantics — implemented for SSH - -- Add the transport-neutral `PtyConsumerSession` state machine and only the SSH - readiness adapter decision above. -- Implement authentication, generation, capability intersection, activation - fence, close cleanup, exact sentinel/residue transfer, and legacy fallback. -- Carry the endpoint credential through split-SFTP per-launch namespace - mapping, with secure canonical shell-path generation when no marker exists. -- Local in-process, daemon hello, and remote-runtime adapters reuse semantics - only when their own changes need it; they are not prerequisites for SSH V1. - -### Slice 5b: direct SSH source credit — implemented - -- Add the relay immutable span ledger/scheduler and tokenized cumulative ACKs - for one direct SSH desktop/model consumer path. -- Return exact `sourceActivation` metadata and install its provisional receive - lease synchronously in mux `beforeResolve`; commit only after provider and - exit-race validation. -- Reserve reentrant ACKs only at the exact pending same-token boundary and - retry that boundary after failed send settlement. -- Reuse Slice 4's projection identities and transactional admission. Separate - obligations terminal, ACK queued, and ACK published state from the first - implementation. -- Offer V1 on every new session while retaining bounded legacy compatibility - for capability-omitting clients and method-not-found relays. - -### Slice 5c: exit, reconnect, and replay — implemented - -- Implements sealed-unsettled exit, cancellation proofs, owner reconnect grace, - token replacement, and exact transfer. -- Carries recovery as contiguous source-bearing `pty.data` from checkpoint to - recovery end; fence live output with single-flight, capacity-retryable - `pty.recoveryComplete` writer settlement. -- Adds a bounded source-range index beside the current - `RecentPtyOutputBuffer`, preserving its append performance and legacy - output. -- `SshPtyOutputModelMigration` freezes exact generation/PTy admission, - `SshRelaySession` awaits the returned per-PTY Promise before constructing - `sourceRecovery`, and `OrcaRuntimeService` detaches only the failed PTY's old - headless model before snapshot-backed restore. -- `SshPtyModelAdmission` retains generation-fatal callback handling except when - the exact key is actively migrating; `SshPtyOutputIntake` uses the same key - to keep provider close from escaping that migration owner. -- Reconnect always re-offers V1; its late-ACK, timeout, supersession, and - generation-close deterministic oracles pass. - -### Slice 5d: required remote consumers — deterministic seam implemented - -- Map remote `terminal.multiplex` encoded-byte ACK frames to accepted source - ranges without changing its transport authentication or granting it an SSH - owner lease. -- Add `ackOutputSourceRanges: 1` and an opaque echoed stream generation; - preserve `ackOutput: 1` delta semantics for old clients and best-effort - streams. -- Make ACK-overflow snapshot recovery reserve, publish, commit, then trim; - rollback before detaching on any failure. -- Keep mobile notification replay epochs/watermarks outside terminal stream - identity and ACK mapping; neither can settle a PTY source range. -- Extend the extracted `terminal-output-frame-chunks.ts` seam with immutable - composite mapping input while preserving `694363805`'s code-unit scanner, - allocation profile, sequence rounding, and equivalence benchmark. -- Live headed paired-server and headless `orca serve` validation remains - required before claiming those topologies; neither is inferred from Docker - SSH. -- Keep best-effort remote streams outside upstream obligations. - -### Slice 5e: adversarial lifecycle reconciliation — implemented - -- Retain canceled invalid-checkpoint deliveries through exact - `restoreRequired` response settlement, then retire only the current record. -- Fail closed on the exact provider generation when a current recovery - cancellation cannot publish or its proof rejects; release generation-owned - resources once without clearing physical PTY ownership. -- Allow partial cumulative remote byte ACKs to release byte budget without - settling an incomplete source frame, and admit only contiguous strictly - higher client/owner generations with a new token. -- Make encoded snapshot replacement commit idempotent; reject reclaimed-span - commits and make rollback, settle, and detach prune absent spans safely. -- Split main-to-relay writes into liveness/control/ordinary FIFO lanes, select - control first after drain, and guarantee ordinary progress after four - controls. -- Hold provisional activation data until provider/claim/exit validation - commits for spawn, or until a validated reconnect contract installs its - private quarantine; rollback owns pre-transfer failure, while cancellation - proof or generation close owns post-transfer failure. -- Acquire exit cancellation proof before projection transfer and proof commit; - prepare/finalize once and fence every post-proof step by provider generation. -- Keep timeout cancellation authority operation-local and compact monotonic - closed provider generations into exact merged ranges without weakening stale - event rejection. -- Seal private recovery on exact exit, retain rollback authority through a - stale transfer, and include every exact-token private frame in the - cancellation-proof watermark. -- Replace process-lifetime per-token blocking state with one latest ordered - cancellation record per relay PTY, cleared only at an exact lifecycle - boundary. - -### Slice 5f: remaining topology validation and adapters — deliberately deferred - -- Local in-process and daemon hello remain unchanged; neither adopts SSH - framing or source credit in this PR. -- WSL, Windows ConPTY/named-pipe, local daemon/provider, folder workspace, - mixed-version, and prior-version-orphan live evidence remains separate. -- Promote reliability-gate provider coverage only for topologies with recorded - executable evidence. - -Keep new lifecycle ownership out of already-large modules where a concrete -domain file is clearer. Do not add max-lines disables, generic helper modules, -native dependencies, or a per-file lint exception. - -## Tests - -The checklist below is the normative matrix, not a claim that every physical -topology or the current shared-worktree diff ran. Implemented seams and prior -recorded evidence include: - -- shared session negotiation, grant publication, stale-owner recovery, source - ledger, ACK queue/publication separation, and cancellation proof contracts; -- relay writer admission/drain/liveness, zero-display source publication, - operation-ID retry activation, fixed-size filesystem compatibility, - capacity-aware Git/PTY slicing, decoder caps, and token rotation; -- main model admission, desktop identity/rollback, exit barriers, remote - reserve/commit/rollback, token cancellation, exact recovery continuity, - eight-wide reconnect, retention budgets, unconditional negotiation, and - deployment; -- current-working-tree restore-response retirement, fail-closed recovery cancellation, - partial remote ACK, higher-generation token rotation, reclaimed-span - idempotence, three-lane mux fairness, provisional claim gating, and - generation-fenced exit proof ordering plus bounded exact generation closure; -- the four-test Docker OpenSSH/deployed-relay suite for source-window plateau, - concurrent typing, fixed-size filesystem/Git churn, and owner reconnect. - -The earlier rebased checkpoint at -`38fbf3520741ce4ccd86410f7cea9ff8217a7834` passed: - -- frozen dependency setup, full lint, full typecheck, `git diff --check`, and - the 52-gate reliability manifest validation; -- all Linux, macOS, Windows, and WSL relay build targets plus - `electron-vite build --mode e2e`; -- 24 focused SSH/source-credit files with 354 tests; -- 14 watcher/daemon/package/glibc-adjacent files with 409 tests; -- a 57-file deterministic changed-surface sweep with 1,186 tests; -- the joined renderer/headless snapshot seam with 59 tests and its broader - focused slice with 180 tests; -- the relay recovery and fixed-size filesystem/Git slice with 105 tests. - -That checkpoint's Docker OpenSSH/deployed-relay run passed all four tests in 1.0 minute: -direct typing median/worst was 107.7/113.6 ms; ACK-stalled typing was -3.6/107.3 ms at an exact 262,144-source-unit plateau; fixed-size filesystem/Git -churn was 148.1/161.1 ms with 93 bulk reads; owner reconnect completed in -15.7 seconds. These measurements are direct SSH/deployed Linux relay evidence -only. - -The four-case Docker results predate the final adversarial lifecycle -reconciliation and are not current-head proof for those races. On the earlier -working tree, a deterministic test reproduced recovery completion becoming -visible at source unit 4 while lease-held recovery ended at 8. After the -validated contract began private quarantine ownership before awaiting the -body, seven provider/session files passed 94 tests and the isolated deployed -Linux relay/direct-SSH reconnect case passed in 17.7 seconds with 256 recovery -frames plus post-reconnect terminal/filesystem proof. Against the same -private-transfer artifact, the final four-case run passed in 1.1 minutes: -direct typing was 6.2/7.4 ms median/worst; ACK-stalled typing was 5.5/16.3 ms -at exactly 262,144 held source units; fixed-size filesystem/Git churn was -141.2/151.5 ms with 95 bulk reads; reconnect completed in 16.0 seconds. All -other live topologies remain separate. - -After the exact-PTY migration-failure containment fix and current-main merge, -`electron-vite build --mode e2e` and `pnpm run build:relay` produced the exact -artifact hashes recorded above. The four-case Docker command then passed in -56.7 seconds without a source-credit override: direct typing was 5.3/108.9 ms -median/worst; ACK-stalled typing was 4.8/107.6 ms at exactly 262,144 held source -units; fixed-size filesystem/Git churn was 152.0/173.3 ms with 84 bulk reads; -reconnect completed in 14.3 seconds. This is direct SSH/deployed Linux relay -evidence only. - -At the implementation commit above, the source/intake slice passed 12 files and 205 -tests, the provider/session slice passed 16 files and 192 tests, and this -complementary lifecycle slice passed 13 files and 150 tests: - -```bash -pnpm exec vitest run --config config/vitest.config.ts \ - src/relay/relay-pty-source-restore-retry.test.ts \ - src/main/ssh/ssh-relay-session-recovery-races.test.ts \ - src/main/runtime/rpc/terminal-source-range-ledger.test.ts \ - src/main/runtime/rpc/terminal-multiplex.test.ts \ - src/main/ssh/ssh-multiplexer-transport-writer.test.ts \ - src/main/ssh/ssh-channel-multiplexer-backpressure.test.ts \ - src/main/providers/ssh-pty-notification-routing.test.ts \ - src/main/providers/ssh-pty-source-delivery-ledger.test.ts \ - src/main/ssh/ssh-pty-retired-source-deliveries.test.ts \ - src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts \ - src/main/providers/ssh-pty-provider-exit-race.test.ts \ - src/main/ipc/ssh-pty-output-exit-deadline.test.ts \ - src/main/ipc/ssh-pty-remote-source-range-consumers.test.ts --reporter=dot -``` - -The deterministic recovery seam specifically covers an empty recovery followed -by a gapped live frame, overlapping reconnect attempts, late frames after -cancel, exact exit during private recovery, stale-transfer rollback authority, -capacity-rejected and post-restore proof watermarks, typed stale-owner retry, -response-settled `restoreRequired` retirement, completion arriving before -lease-held body frames, and fail-closed exact-generation cleanup. A separate -10,000-rotation oracle proves canceled-token retirement remains one record per -PTY. Transferred recovery data remains private until body/fence validation, -and a proved token-local failure causes no physical PTY shutdown, ownership -delete, or lease expiry. -The model-migration failure oracle uses two PTYs in one provider generation: -one raw callback rejects under an active fence, returns -`checkpointUnavailable`, resets one model, and releases its charge and timer; -the sibling checkpoint, provider mux, filesystem/Git transport, and another -provider generation remain live. A paired admission oracle proves the same -failure without an active migration still closes the generation and rejects -its sibling receipt. -The remote-consumer seam covers partial cumulative ACK, safe higher-generation -token rotation, and token cancellation followed by late detach/replacement -commit, proving reclaimed span IDs reject commit, preserve rollback ownership, -and are then cleaned up idempotently. - -### Normative unit and property matrix - -- accept monotonic cumulative ACKs and reject duplicates, regressions, - over-credit, invalid numbers, wrong clients, wrong PTYs, and stale tokens; -- prove with generated span/ACK sequences that - `sentEndSu - creditedEndSu <= windowSu`; at `window - 1`, slice a - splittable frame to one source unit and hold an indivisible transform; -- generate merge, split, remainder, salvage, thinning, empty-transform, and - interactive-bypass queue operations and prove ledger conservation; -- split DEC mode 2031 subscribe and withdraw sequences across chunks, then - inject pending-cap salvage, rollback, transfer, reconnect, and source gaps; - prove fact order, exact scanner restoration/reset, no duplicate reply, and no - source settlement from projection metadata; -- put two clients at different display/source offsets in one shared span and - prove no resend, skip, or early reclamation; -- cover ASCII, BMP/CJK, surrogate pairs, unpaired surrogates, JSON-escaped - controls, and transformed spans where `rawLength !== data.length`; assert the - budget arithmetic and reject transformed frames without valid `rawLength`; -- run the shared semantic state-machine suite and the implemented SSH - socket/named-pipe adapter suite; local and daemon V1 adapters remain - unimplemented, direct-stdio/WSL has deterministic common-transport evidence - only, and remote-runtime source mapping is implemented at a deterministic - seam but unexecuted in a live paired topology; -- verify every new SSH session and reconnect offers V1 and every same-build - relay advertises and grants it; separately verify a capability-omitting old - client remains token-free and the narrow method-not-found relay fallback is - bounded; cover invalid/stale credential or lease rejection, 30-second lease - expiry, and atomic reconnect generation transfer through POSIX sockets and - Windows named pipes; prove constructor stdout, WSL child stdio, and an - unproved plain socket are never elected, and cover POSIX `0600` plus Windows - current-user ACLs; -- verify split shell/SFTP home discovery, per-launch marker creation under the - held install lock, credential write before lock release, canonical shell-path - fallback, system-SSH bypass, and unconfirmed teardown lock retention; -- verify token rotation on spawn, attach, reconnect, provider replacement, and - same-client duplicate attach/spawn; replacement must cancel the old token - once, report its exact remaining span, and transfer only matching recovery - coverage while canceling the already-checkpointed prefix without re-ingest, - duplicate output, or an open cursor; -- keep one lossless remote stream attached across an unsettled old-token frame - and a contiguous new-token frame; require strictly higher client and owner - generations, the same provider/incarnation, a new token, and reject every - stale or partially advanced identity; -- verify metadata response callbacks precede source-ranged recovery and live - data in V1, including saturation with older control; use multi-frame - all-control-character gaps to prove source bodies remain ordinary - producer-owned `pty.data` rather than control responses; -- feed response, recovery, completion fence, and first live data in one decoder - turn; prove synchronous `beforeResolve` installs a provisional cursor at - `checkpointSourceEndSu`; deliver completion before lease-held frames, then - prove validated reconnect transfers those frames only into private - quarantine, routes ranges through `recoveryEndSu`, and projects them once - only after exact body/fence validation. Pre-transfer rollback drops held - data, restores only the active predecessor, and awaits exact - token-cancellation proof; -- reject wrong-incarnation checkpoints and checkpoints below old credit or - beyond retained live end; require a token-free `restoreRequired` response - with no partial recovery or forward clamping; retain the canceled record - through response settlement, retire only the exact current record, then - prove retry mints a fresh token and publishes one live source frame; -- send identical recovery `pty.data` for distinct tokens within one second and - prove token/range identity, not a wall-clock fingerprint, controls admission; -- retry an operation-ID spawn after commit/before response from a new client; - prove it receives a fresh subscription and the stale client cannot ACK; -- assert `write(false)` admits exactly one frame, preserves the control reserve, - and admits no ordinary later frame before drain; -- queue ordinary PTY input before source ACK, cancellation, exit, request, and - response frames; after drain require liveness/control selection first, FIFO - within all three lanes, and one ordinary selection after four controls; -- deliver a cumulative ACK reentrantly before the source writer callback; - reserve only the exact same-token pending boundary, then prove successful - settlement applies it and failed settlement permits only an exact retry; -- inject asynchronous Node write-callback errors for session grants, token - activation, recovery completion, exit, and ACK publication; prove none - advance state and the client generation closes with exact cancellation; -- reject one `pty.recoveryComplete` admission, fire repeated capacity signals, - and prove one bounded retry is admitted; hold its callback and prove no live - output or duplicate completion passes before successful settlement; -- reject a PTY subscription whose empty non-reserved sink capacity is below - 8 KiB; prove every admitted transformed span remains writable; -- saturate relay fs/Git bulk; preserve fixed 256 KiB `fs.streamChunk` frames in - the exclusive empty-sink lane, slice only reshapable bulk/PTY data, and bound - cancellation, keepalive, ACK, immediate echo, and control latency; -- assert sentinel, handshake residue, and connect-mode socket data use one - stdout writer in FIFO order under saturation; -- assert close, error, detach, invalidate, replacement, reset, and dispose - settle/cancel each callback, token, and pause epoch exactly once; -- cover every lifecycle-table row and reject any - `received != open + transferring + settled + transferred + canceled` state; -- require `pty.deliveryCanceled` or generation-close proof for every - relay-initiated cancel and reject stale cancellation notifications; -- delay headless-emulator callbacks indefinitely; prove per-PTY/global model - queues and the one-frame intake slot plateau, upstream credit stops, low-water - resume is exact, and emulator rejection cancels rather than credits; -- while model admission is blocked, place cancellation/control behind a - bounded PTY burst; prove the 1 MiB/64-frame reserve services it without - reordering data, and prove reserve exhaustion pauses reads then closes the - provider at the deadline with cleanup proof; -- under the same pressure, inject `data -> pty.exit` and - `recovery pty.data -> pty.recoveryComplete`; prove both lifecycle fences - remain behind their token's quarantined data, while a same-token cancellation - proof bypasses only after atomically canceling that prefix; -- reject recovery cancellation publication and local proof application while - the attempt is current; prove the exact provider generation, mux, provider, - publishers, transferred activation, and registration close once, while - unrelated generations and physical PTY ownership remain untouched; -- prove renderer ACK/heal/write-off on V1 settles/transfers via display ranges - and emits no legacy `acknowledgeDataEvent` wire delta; -- admit interleaved spans from two token generations and prove every desktop - operation resolves the immutable span ID, PTY incarnation, delivery token, - source/display interval, and transform metadata; inject model-reservation, - projection-admission, send, merge/split, salvage, reload, and replacement - failure at each transaction boundary and prove rollback or exact transfer; -- generate remote UTF-8, JSON-escaped, transformed, split, and composite frames; - keep legacy `ackOutput: 1` delta ACKs outside source obligations; negotiate - `ackOutputSourceRanges: 1`, then apply partial, excessive, and stale-generation - cumulative ACKs and prove a partial ACK releases only its exact byte delta, - retains the covering source frame, and settles source only at recorded frame - boundaries, with send-stop without auto-transfer at the cap and atomic - mapping transfer on an explicit reconnect/detach; -- overflow ACK-pending remote output and inject snapshot serialization, - publication, exact-generation commit, and rollback failures; prove covered - chunks trim only after commit and recovery remains single-flight; after - cancellation proof reclaims a reserved span, require commit rejection, - idempotent rollback, and safe late settle/detach pruning; -- feed current mobile notification epochs and watermarks into terminal ACK - handlers and prove they are rejected without changing any source obligation; -- extend `terminal-output-frame-chunks-equivalence.test.ts` so composite source - identity preserves the current code-unit chunk text, encoded bytes, sequence - rounding, and allocation/performance contract; -- retain `terminal-stream-byte-length.test.ts` equivalence at its native-call - floor and over-limit boundary so transport byte accounting and batch flushes - cannot silently become source-unit accounting; -- hold desktop parse ACKs while a lossless mobile/web consumer advances; prove - desktop transfer to model restore allows upstream credit to continue; -- cover pause/resume hysteresis and re-entrant output/exit callbacks; -- drive `SshPtyProvider.pauseProducer`/`resumeProducer` through - `pty.setDeliveryPaused`; prove token/generation scoping, idempotency, native - pause when all required deliveries are ineligible, and continued progress - for a healthy additional subscriber; -- run one slow and one healthy client, verify only the slow client is detached; -- run no clients, verify no live pending data, bounded replay, and an unpaused - producer outside reconnect grace; -- verify exit follows data and never bypasses the window; after the exit write - callback, keep the sealed token and uncredited suffix live, accept a late - cumulative ACK, advance main `ackPublishedEndSu` only from its write callback, - and close the relay record only after it applies that ACK; separately prove - timeout publishes cancellation or uses generation-close proof and affects - only the stalled PTY token; -- receive exit with queued and in-flight emulator writes plus desktop and - remote mappings; prove main delays `runtime.onPtyExit`, renderer exit, model - disposal, and subscription cleanup until prior owners settle/transfer, and - prove deadline first acquires cancellation proof, transfers published - projections, commits proof, prepares/finalizes once, and rejects late - generation callbacks; close the generation while proof is pending and prove - no final exit publishes, then separately make proof fail while current and - prove only then the provider closes; -- close 2,048 sequential provider generations and prove one retained exact - closed range rejects early, middle, and latest stale events; close out of - order and prove an unclosed older generation remains admissible until its own - close merges the gap; -- verify round-robin progress across 50 continuously active PTYs; -- verify spans are freed only after all divergent cursors advance, cancel, or - transfer; -- test every source-unit and byte budget at limit minus one, limit, and plus - one, including global low-water hysteresis and legacy per-client/relay byte - publication caps plus their drain-owned low waters; -- verify decoder order, synchronous first frame, bounded yields, transport - pause/resume through ssh2, system SSH, WSL child stdio, stdin, Unix socket, - and named-pipe adapters, reset cancellation, partial 16 MiB frames, input-cap - close, oversized discard, and exact handshake residue; -- keep an unacked header across decoder/read self-pause and writer saturation; - prove resume rebases both health clocks, coalesces keepalives, caps timestamp - entries, and gives a full timeout window; -- feed hostile `ack=0xffffffff` and prove work is proportional to pending map - size, never the numeric ACK range; -- prove terminal obligations advance `obligationsTerminalEndSu` before any ACK - write, coalescing advances `ackQueuedEndSu`, callbacks alone advance - `ackPublishedEndSu`, and cleanup waits for the published end; cover failed - writes, cumulative retries, at most 64 entries, 8 ms/64 Ki-su flush rules, - and input fairness; -- fuzz frame boundaries, JSON sizes, reconnect timing, transport data during a - decoder continuation, and scalar-safe Unicode splits; -- exercise unconditional same-build V1 negotiation, old-client capability - omission, and method-not-found fallback; reject a manually mismatched - `.version`, and identify—but do not adopt or kill—an orphaned prior-version - daemon; -- verify the session grant rejects unrequested versions and zero, negative, - excessive, non-finite, or unsafe windows. - -Use a deterministic fake sink that records accepted frames separately from its -saturation signal. Use a fake native PTY whose `pause()` can synchronously emit -one last chunk to prove the transient overshoot bound. - -### Integration and E2E - -`tests/e2e/ssh-docker-relay-perf.spec.ts` now contains V1 evidence for the -implemented Linux SSH/deployed-relay path. The test harness exercises the -normal deployment and connection path with no override and: - -1. stalls desktop ACK and observes an exact 256 Ki-source-unit negotiated - plateau while a second SSH PTY remains responsive; -2. preserves fixed `STREAM_CHUNK_SIZE` filesystem semantics and completes Git - churn without corruption while active typing stays within budget; -3. reconnects the negotiated owner lease and proves the existing SSH - workspace terminal remains usable; -4. retains the direct typing latency control. - -Writer `write(false)`/drain, callback failure, liveness bypass, decoder -scheduling, sealed exit, stale ACK, exact recovery, and remote replacement are -proved at deterministic unit/service seams. The Docker suite does not force -the main-to-relay sink to remain saturated beyond the health deadline, does -not prove paired-runtime behavior, and must not be cited for those claims. - -Docker SSH proves the Linux SSH provider/relay path only. It must never be -reported as headed paired-server, headless `orca serve`, remote-runtime -lossless subscription, macOS node-pty, Windows ConPTY/named pipe, or WSL -evidence. - -The Slice 5d mapping is implemented, but live paired-runtime coverage remains -a separate promotion requirement: - -| Topology | Required oracle | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------- | -| Headed paired desktop server + separate client | host-owned PTY identity, source mapping, client ACK/restore, reconnect, and cleanup | -| Headless `orca serve` + same client flow | identical source settlement plus headless model admission/startup ownership | -| Direct Docker SSH | deployed relay transport, main desktop intake, real remote process, writable/drain behavior | -| WSL child stdio | sentinel/residue, read pause, writer drain, no POSIX-path assumptions | -| Windows native | ConPTY pause/resume and named-pipe callbacks/drain | -| Local daemon | authenticated paired hello and exact-version legacy fallback | -| Local provider | direct semantic instantiation with no relay framing | - -Each topology also runs in a folder workspace fixture with no `.git`; source -identity and cleanup never depend on worktree metadata. Prior-version daemons -remain version-scoped legacy processes: report them, do not negotiate V1, -adopt their PTYs, or claim the new main bounds their memory. Keep any new -native artifact compatible with Ubuntu 20.04/glibc 2.31; this design itself -adds none. - -The -`terminal-performance.output-backpressure-budget` reliability gate remains -`experimental` and `partial`. Its `coveredProviders: ["ssh"]` means the -deterministic direct-SSH contracts plus the macOS-hosted Docker -OpenSSH/deployed-relay run described above. It does not cover every SSH host -platform and does not mark local, daemon, remote-runtime, headed, headless, -WSL, Windows, folder-workspace, prior-version, mixed-version, or Ubuntu 20.04 -packaging topologies as executed. The WSL stdin write/callback/drain contract -is deterministic adapter evidence only, not a physical WSL run. - -## Compatibility and release validation - -Every new SSH session and automatic reconnect offers V1. Every relay from the -same build advertises and grants V1, so the normal deployment path always uses -source credit. Sink drain, the reserved writer lane, stdout serialization, -bounded decoder work, bulk admission, and hostile header-ACK hardening are -also unconditional. - -Compatibility behavior is explicit: - -1. An old client that omits the V1 capability remains token-free and uses the - bounded legacy writer. -2. A new main may use bounded legacy delivery only when the same-build - compatibility check accepts JSON-RPC method-not-found for - `pty.openClient`. -3. A successful `pty.openClient` response without the offered V1 grant, an - invalid grant, or another request error fails closed rather than silently - downgrading. -4. Disconnect performs normal token-scoped cancellation and exit cleanup; it - never rotates a live V1 token into legacy mode in place. -5. A prior-version orphan remains governed by its own version-scoped binary and - cleanup. The new main reports but does not adopt, kill, or claim to bound it. - -Implementation and validation status: - -1. Slices 1-4 are implemented under legacy-compatible framing with narrow - reliability oracles. -2. Slices 5a-5c implement unconditional V1 offer/advertisement, source credit, - exit, and reconnect with unit/service contracts and direct Docker SSH - evidence. -3. Slices 5d-5e implement remote source-range transactions and the final - lifecycle reconciliation at deterministic main/runtime/provider/relay - seams. Live headed paired, headless, WSL, Windows, local-daemon, - local-provider, and folder-workspace evidence remains future work before - those topologies are marked covered. -4. Current-working-tree deterministic reconciliation and focused integrated-fix checks - are complete. The isolated reconnect and final four-case direct-SSH Docker - runs, full repository typecheck, and full lint/reliability suite are green; - final exact-implementation-head architecture and transport reviews found no - code blocker, and the transport review's stale-provenance finding is closed - by the exact artifact record above. - -Release criteria: - -- no unbounded increase across 30-minute desktop-only, slow-model, and required - lossless-consumer floods; -- relay and main stay within the documented budgets plus 25% allocator slack; -- active echo p95 remains below 100 ms under a background flood; -- exact logical output sequence after resume; -- no leaked obligations, writer callbacks, drain waiters, cursors, tokens, - decoder tasks, or paused PTYs; -- no false dead-link reconnect across a 30-minute saturated/self-paused run; -- bounded time-to-last-reattach with one injected per-PTY failure; -- unconditional same-build V1 negotiation, bounded legacy compatibility, - manual mismatch rejection, orphan reporting, and Linux/macOS/Windows/WSL - smoke tests pass. - -## Calibrated findings and non-goals - -- The liveness risk is real for transport saturation and self-paused decoding. - A renderer-only stall is absorbed by bounded projection transfer/restore - while required-model credit continues. A stalled model admission or - negotiated lossless remote consumer exhausts its window and pauses the - native PTY; tests keep these distinct. -- V1 uses logical priority lanes and reserved writable capacity, not a second - SSH channel. Adding a physical control channel would widen handshake, - reconnect, system-SSH, and legacy compatibility scope without being required - once PTY bursts and decoder pauses are bounded. -- There is no arbitrary 16-socket cap. Additional PTY projections are bounded - and isolated by their own writer/publication capacity; short-lived remote CLI - clients remain independent. -- A 60-second watchdog may warn about an old open obligation, but it cannot - force-credit data. Recovery must use a proven transfer or token cancellation. -- Reconnect-grace data is a bounded live-delivery owner, not an expansion of the - 100 Ki replay-tail contract. Expiry is an explicit data gap, never silent - truncation. -- Content-hashed endpoints make mixed production binaries unreachable. The - real upgrade skew is an orphaned prior-version daemon outside the new main's - startup-gated endpoint; V1 reports but cannot adopt, cancel, or retroactively - bound it. -- Preserving the real exit code for a PTY that exits before any new client - reattaches would require attach-visible tombstone retention. V1 documents and - tests the existing synthetic `-1` fallback instead of coupling that separate - product change to backpressure. - -## Reference-commit assessment - -Retain from `1500a92904` the useful concepts of generation tokens, client -isolation, pause/resume, targeted dispatcher sends, cleanup of stale credits, -and slow-client eviction. - -Do not reproduce these behaviors: - -- ordinary notifications continuing to write after `write(false)`; -- delta ACKs without cumulative token scope; -- automatic live broadcast to every connected client; -- force-flushing final PTY output beyond the credit window; -- per-client windows without aggregate retained-memory budgets; -- synchronous all-frame draining; -- assuming `256 Ki × 50 PTYs × N clients` is itself a safe memory bound. - -This design makes writable-drain state, application credit, retained memory, -and decoder CPU four explicit and independently enforced bounds. - -## Platform and workspace requirements - -The relay can run on a native macOS, Linux, Windows, or WSL host reached over -SSH. Use Node stream APIs and runtime platform checks; do not assume POSIX file -descriptors, path separators, signals, or Unix sockets. Windows uses ConPTY and -named pipes, while POSIX uses node-pty and Unix sockets, but both must expose -the same idempotent pause/resume and sink state contract. - -Network latency changes ACK cadence but not the window invariant. A -high-bandwidth/high-latency SSH link may need later window tuning based on -telemetry; it must not receive an unbounded adaptive window. SSH disconnects -are normal lifecycle events, not exceptional shortcuts around cleanup. - -No part of subscription identity or cleanup may depend on `.git`, a worktree -ID, or a Git provider. Folder workspaces use the same connection, PTY, and -client generations as Git worktrees. Bulk Git changes only slice and schedule -existing response frames; they add no Git command, provider-specific behavior, -or requirement beyond the Git 2.25 core-workflow baseline. diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md deleted file mode 100644 index 104f6409929..00000000000 --- a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md +++ /dev/null @@ -1,279 +0,0 @@ -# Daemon lifecycle retirement for issue #9138 - -## Status - -Implemented and locally validated for PR #9277. - -The earlier full ownership/audit prototype is preserved at: - -- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot` -- commit: `7c915909bd26670b8af36aa683cff85077395dd1` -- GitHub: - -That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup -audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future -legacy enforcement. None of those systems ship in this PR. - -Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was -in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation -alongside v22 and older versions. - -## Inputs and scope decision - -Issue: - -Reviewed design comment by AmethystLiang: - - -The reviewed comment correctly identifies two different problems: - -1. current-generation daemons have no daemon-owned empty lifecycle; -2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act. - -This PR solves only the first problem. The second problem is much larger, adds steady-state -persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement. -Keeping it out makes the user-visible fix small enough to review and benchmark independently. - -The narrow implementation retains these important rules from AmethystLiang's design: - -- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it; -- live sessions always win over cleanup; -- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence; -- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation; -- the daemon, not app startup, makes the atomic empty decision; -- pre-v24 daemons stay reattachable and are not automatically shut down; -- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged. - -The narrow implementation changes one policy from the original comment: it does not use a blanket -30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle -event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime -inactivity heuristics and periodic ownership work. - -## User-visible behavior - -```text -Before - -app disconnects ──> daemon stays forever - ├── live sessions stay (wanted) - └── zero sessions also stay (leak) - -After - -clean app detach ──> daemon atomically checks itself - ├── any live session/work/client ──> stay alive - └── exactly empty ──> exit immediately - -unexpected drop ──> daemon atomically checks itself - ├── live session/work/connection ──> stay alive - └── exactly empty ──> exit immediately - -v23 and older ──> existing reattach behavior; no automatic retirement -``` - -An end user with live terminals should notice no change. An end user who quits with no daemon-backed -terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket, -live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later -app restart launches a fresh daemon instead of reusing an empty process. - -## Protocol and lifecycle design - -### Endpoint identity - -Protocol v24 hello responses include: - -```ts -type DaemonEndpointIdentity = { - pid: number - startedAtMs: number - launchNonce: string -} -``` - -The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes -the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client -sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps -the previous identity-free handshake. - -PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a -write failure terminates the new child and fails launch instead of leaving an untracked daemon. - -### Clean detach - -At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a -v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared -250 ms budget. v23 and older adapters skip it. - -Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is -opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still -receive clean retirement on quit. If startup fallback has already won, the late daemon is not -installed; it instead receives the same bounded retirement attempt, which an adopted live session -will reject. - -The daemon accepts retirement only when, in one event-loop turn: - -- the requesting authenticated client has both control and stream sockets; -- it is the only authenticated client; -- every accepted transport belongs to that client; -- no `createOrAttach` operation is in flight; -- the terminal host has zero sessions. - -When all conditions hold, the daemon synchronously closes the listening server before replying. That -is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then -runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to -the same event-driven empty check when the authenticated sockets close. - -### Initial adoption watchdog - -A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated -client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the -normal launch handoff before the parent could connect; without a bound, a parent crash during that -handoff would orphan the new daemon forever. - -A complete pair permanently cancels this watchdog, and terminal admission requires that complete -pair. Raw and partial transports pause it without extending its original deadline. It is never -rearmed after adoption and is not a terminal inactivity or crash-reconnect timer. - -### Unexpected disconnect - -When the last client that completed both authenticated sockets loses its control connection, the -daemon records an event-driven retirement request with no wall-clock grace. - -- A complete authenticated reconnect cancels the request if existing work or a transport kept the - daemon alive long enough to reconnect. -- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request - before admission can begin. -- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but - cannot erase evidence that the last fully connected app left. -- Replacing a client ID first records the old full connection's loss; completing the replacement - stream cancels that evidence, while an incomplete replacement only blocks retirement. -- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately - rechecks every guard and retires only if it is then exactly empty. - -The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the -token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is -not broadened into destructive or respawn authority. - -### Artifact cleanup - -The daemon removes only artifacts it can claim as its own: - -- token contents must match the daemon's in-memory token; -- PID and launch nonce must match the daemon process and launch nonce; -- cleanup first renames the canonical entry to a unique claim, validates that claim, and never - overwrites or unlinks a replacement installed at the canonical path. - -Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24 -PID/socket artifacts. Legacy cleanup behavior is unchanged. - -## Performance design - -There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or -steady-state persistence write in this PR. - -The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one -two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its -connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection -attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect -bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one -unref'ed startup watchdog, which is canceled permanently on adoption. - -Validation compares current main and the branch for: - -- daemon connect plus two-socket hello latency; -- repeated `listSessions` RPC latency; -- terminal echo/stream throughput through a real socket daemon; -- clean empty-detach latency; -- event-loop delay under repeated RPC/stream work; -- idle CPU/RSS and timer count where observable. - -The acceptance target is no statistically meaningful terminal throughput regression and no new -steady-state disk writes. Results are recorded in the PR body. - -### Local performance regression screen - -The final local host was not quiet enough for publication-grade absolute numbers: load averages were -17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired -same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24 -branch samples; medians across sample medians were: - -| Measure | `main` v23 | branch v24 | -| -------------------- | ---------: | ---------: | -| two-socket connect | 1.38 ms | 1.32 ms | -| `listSessions` RPC | 0.0366 ms | 0.0374 ms | -| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s | - -All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and -event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression -screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work -runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY -byte and no steady-state persistence or polling. - -## Verification and validation - -### Focused unit and integration tests - -- v24 requires valid matching endpoint identity on both sockets. -- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols. -- production launch passes PID path and nonce and writes the exact readiness identity. -- incomplete readiness identity or failed exclusive PID publication kills and rejects the child. -- clean empty detach exits immediately. -- a never-used current adapter connects and retires cleanly on quit. -- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old - deadline. -- a live session, another client, raw transport, or in-flight admission rejects clean retirement. -- a control-only overlapping client blocks but cannot erase the last full-client retirement request. -- a same-client-ID control replacement cannot erase the prior full connection's retirement request. -- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed - request. -- startup fail-open performs bounded empty retirement without installing a late provider. -- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later. -- the synchronous listener fence rejects post-fence connections as retryable. -- an unexpected empty disconnect retires immediately without a runtime inactivity timer. -- a real reconnect cancels pending retirement while live work keeps the daemon available. -- raw and health probes block but cannot erase pending retirement. -- final session exit triggers an immediate guarded retirement check. -- token/PID cleanup preserves malformed, stale, and replacement artifacts. -- authenticated token disappearance performs one coalesced respawn; initial token absence does not. - -### Process/E2E tests - -- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts; -- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts - exit; -- prove a live session rejects retirement and remains reattachable; -- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable; -- never target a production runtime directory or signal a process not created by the fixture. - -### Repository validation - -- Node typecheck; -- oxlint and repository max-lines policy; -- focused daemon, adapter, launcher, restart, and legacy-routing suites; -- full daemon test suite; -- desktop and web production builds; -- `git diff --check` and review of every changed file against `origin/main`; -- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`; -- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication. - -Final local results on macOS arm64 after the event-driven policy revision: - -- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped; -- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned - artifacts retired immediately after its final authenticated client disconnected; -- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed; -- full desktop, web, and native production build passed with existing build warnings; -- three independent post-revision review tracks covering architecture/state machines, - ownership/adoption, and process lifecycle ended clean after actionable findings were fixed. - -The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed -successfully, and repository CI remains responsible for the supported Node/platform matrix. - -## Rollout and rollback - -This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves -v24 as another legacy generation and does not give an older app authority to shut it down. - -If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated -ownership/audit prototype remains recoverable from the archived branch and commit above; it should -return only as a separately reviewed, benchmarked follow-up. diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md deleted file mode 100644 index e7b13f0fb29..00000000000 --- a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md +++ /dev/null @@ -1,545 +0,0 @@ -# Windows ConPTY Startup Query and Focus Authority Design - -Date: 2026-07-19 - -Status: Implemented with the 2026-07-20 ownership amendment below - -## 2026-07-20 Ownership Amendment - -Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for -the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so -renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder. - -The PTY-owning process now classifies the backend from its own platform and the shell that actually -won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11 -queries before model, replay, or view delivery. During the bounded startup window it replies once -when validated theme colors are available; after the deadline, or without colors, it consumes the -query without replying. A consuming-view handshake does not transfer this authority. Split query -candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a -candidate that proves malformed is released unchanged. - -WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the -startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime -PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is -selected only by the authoritative owner backend, never by renderer or connection metadata. - -## Problem - -On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user -text: - -```text -]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\ -``` - -An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal -focus-in report `CSI I`. - -The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The -agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only -about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in -addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent -to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order -records that cooked echo in the authoritative runtime model before any renderer-bound filtering. - -The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits -DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native -PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was -consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol; -the failure requires an additional agent, timing, input-mode, or replay condition. - -## Root Cause - -OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY, -WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners -([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session` -advances sequence state, writes its emulator, persists pending output, and fans out data before -Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only -filter can therefore misclassify paired runtimes and hide the symptom without removing it from the -authoritative model, daemon history, snapshots, or remote delivery. - -The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer -downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met -that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness -without pre-approving a speculative focus workaround. - -## Data Flow - -```text -node-pty / remote relay PTY - -> shell-ready marker scan - -> source-owned serialized ingress transaction - -> consume an early OSC query and write one canonical reply - -> match or losslessly release the measured native-Windows echo projection - -> authoritative emulator + persistence/history - -> runtime side effects and mobile/remote stream - -> renderer delivery or hidden-drop decision - -> live terminal / snapshot restore -``` - -Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed -spans advance sequence and flow-control state without writing bytes into an emulator or view. - -## Rejected Prototype - -The current working-tree prototype is not the implementation of this design: - -- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus - semantics for every Windows PTY, including programs unrelated to agents. -- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a - hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage. -- Its echo state activates only after both color slots are answered. An echo of the first response - can escape if it arrives before the second query. -- Its partial-match buffer can be lost on timeout, and its substring search can remove a later - legitimate string that happens to equal a reply. - -Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked -echo filtering. Existing unrelated Windows focus-idle safeguards remain. - -## Existing Contracts Preserved - -This design extends, rather than replaces: - -- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query - authority, authoritative model restore, and raw sequence ordering; -- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus- - hidden-dropped ownership and replay silence; -- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY - bytes once in main before renderer delivery. - -The startup responder is a bounded exception needed before normal delivered/dropped ownership can be -established. It must join the same ingestion decision, not operate as an unrelated renderer scrub. - -## Decision 1: Source-Owned PTY Ingress Transaction - -Sanitization must run on the host that owns the PTY, before that host mutates any authoritative -model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced -its sequence, written its emulator, recorded pending output, and broadcast the data before main's -provider callback runs. - -The transaction therefore has three installations of the same shared state machine: - -- in `LocalPtyProvider`, before its configured runtime callback and data listeners; -- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and - attached-client fanout; -- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's - `SshPtyProvider` is only an RPC proxy and never sanitizes relay output. - -Electron main receives already-classified daemon data. A remote-runtime desktop client does not -reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual -provider owner but never enable the native-Windows compatibility projection. - -Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach` -receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed -color attributes. It first decides fresh versus reattach; only a fresh result constructs the -transaction before releasing the subprocess's already-buffered early output. A reattach discards -the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any -PTY id can be reused. - -Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn` -carries the same fresh-session intent and installs it before releasing relay PTY output; relay -`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is -not armed and normal query authority handles the query. - -Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold -predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported -legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a -separate versioned spawn/data capability because daemon protocol support says nothing about relay -support. - -An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots -or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive -the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached -legacy session remains explicitly outside it. - -### Composition with shell-ready preprocessing - -The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold -transport bytes and removes its private ready marker; marker bytes have never belonged to terminal -model or delivery sequence space and continue not to count. Its released non-marker bytes enter the -new ingress transaction in original order. - -Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its -non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model, -persistence, and views observe the resulting emissions. No later stage may introduce an unmetered -string transform. - -### Raw sequence and emission contract - -The state machine accepts source chunks with an explicit raw half-open range and returns zero or -more ordered emissions: - -```ts -type PtyIngressSourceChunk = { - data: string - rawStartSeq: number - rawEndSeq: number -} - -type PtyIngressEmission = { - data: string - rawStartSeq: number - rawEndSeq: number - transformed: boolean -} -``` - -The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when -`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without -overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once -from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the -buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it. - -The ingress raw high-water advances when a source chunk is accepted. Model-applied and -view-delivered high-waters advance only through ordered emissions, including empty transformed -emissions that consume a raw range. Runtime/mobile listener metadata carries -`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span -cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching -the existing `rawLength !== data.length` safety rule. - -This metadata is end-to-end, not local to the state machine. Implementation changes the complete -path: - -1. daemon `Session` or relay `PtyHandler` emission callback; -2. daemon/relay batching, coalescing, splitting, and wire notification; -3. daemon adapter or `SshPtyProvider` decode; -4. `OrcaRuntimeService` model and runtime/mobile listeners; -5. main renderer batching, pending/drop accounting, and preload payload; -6. renderer reconciliation and remote-runtime binary/live frame decoding. - -The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A -span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing -its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted -only for contiguous spans and sums raw lengths independently of string lengths. A transformed -emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush -it as its own frame or request snapshot reconciliation instead of slicing it. - -If a snapshot is requested while a partial candidate is held, the transaction first abandons that -candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and -snapshot sequence therefore describe the same raw high-water. - -### Serialization and teardown - -Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a -nested callback, but that callback is appended after the current source chunk rather than delivered -ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue. - -On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model -and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay -disposal must flush both ingress prefixes and its existing pending output batches before clearing -them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate -state after teardown. Ordinary chunks pass through as one unchanged emission. - -## Decision 2: Startup OSC Queries Are Consumed Authoritatively - -The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup -and the agent's short color-query timeout. Its implementation moves into the source-owned ingress -transaction. - -When registered for an agent spawn, it: - -1. recognizes exact OSC 10/11 query grammar across provider chunks; -2. builds replies from validated renderer-pushed foreground/background attributes; -3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority, - regardless of whether the query ended with BEL or ST; -4. records each reply transaction before writing it to the provider; -5. consumes the answered query from the authoritative model and view emissions so neither the - hidden model nor a delivered renderer can answer it a second time; -6. begins echo recognition as soon as each individual reply is written. - -If attributes or a provider are unavailable, native ConPTY consumes the query without replying; -WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers -startup response state, matching current behavior, but native ConPTY ownership still prevents a -downstream reply. - -### Exact authority transfer - -Startup query response authority opens only for a fresh session whose atomic creation installed the -transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of: - -- both OSC 10 and OSC 11 slots have been answered; -- the startup deadline expires; -- main sends an ordered authority-close control after either the consuming-view handshake or the - hidden-runtime ownership mark is established; -- the spawn fails, is cancelled, reattaches, or exits. - -Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source -replies, while complete queries remain consumed for the life of the PTY. Closing response authority -does not discard already-written reply candidates. Echo recognition has -its own bounded lifetime and may finish or drain after normal authority takes over. A close and a -provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to -a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control -method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered -boundary are either consumed at source or removed from emissions; queries after it pass unchanged -to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority. - -The regular authority rules continue after the bounded startup window: - -- delivered live bytes are answered by the live view; -- hidden-dropped live bytes are answered by the runtime model; -- replayed, seeded, and snapshot bytes are answered by nobody; -- the daemon's persistence emulator never writes replies. - -Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to -name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a -real third responder class, not an undocumented exception. - -## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model - -Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a -compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do -not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and -capability gate. - -The harness records the exact projection ConPTY returns for the canonical ST reply, including its -chunking and any console transformation. The implementation must not assume that the projection is -always merely "remove ESC", and BEL or other reply forms are not added without separate evidence. -For every written reply, the transaction records that exact expected projection. Recognition is: - -- FIFO in reply-write order; -- anchored at the next possible output position, not an unbounded substring search; -- active immediately for each reply instead of waiting for both color slots; -- bounded by the startup deadline and maximum reply length; -- streaming across chunk boundaries. - -If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged -and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is -buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A -match advances the raw span but emits no bytes to the model, persistence, or views. - -Projection matching cannot prove provenance. An application can print the same projected text at -the candidate position, causing a false-positive removal while the later real echo remains visible. -This is the central drawback of the workaround. It is accepted only if the real provider harness -shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise -Orca disables the projection and keeps the visible output rather than risking deletion. - -When the projection is enabled, the exact-collision behavior is explicit: the first identical -anchored candidate is removed and a later real echo is allowed through. The test fixture must assert -that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance -is knowable; the release gate must document the observed timing window and justify that tradeoff. -Unknown, delayed, or interleaved transformations pass through visibly. - -## Decision 4: Preserve ConPTY Focus Protocol - -Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It -must not remove, reorder, or fabricate transport bootstrap modes. - -The `[I` investigation gets a deterministic harness before a behavior change. The harness must -exercise the actual renderer focus callback and provider write path, not only inject `CSI I` -directly into node-pty. It records: - -- raw provider output and its order; -- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004, - or replayed snapshot state; -- the exact bytes written to the provider; -- the exact bytes returned by the provider and stored in the model; -- agent lifecycle state when the report was emitted. - -The implementation gate is strict: - -- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode - is not persisted as application ownership. -- If a live agent receives transport focus before it owns terminal focus reporting, record that - evidence without shipping a suppression heuristic from this document. -- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize - that transformation at the same pre-model ingress boundary used for OSC replies. - -No global `?1004h` filter ships under any outcome. - -If the harness proves a focus-ownership race, a follow-up design is required before implementation. -That design must define the output-to-input ownership signal, distinguish xterm focus events from -identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any -snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document -does not pre-approve an ownership state machine whose input provenance cannot yet be represented. - -## Snapshot and Replay Rules - -Interactive modes in a snapshot are capabilities of the live application, not proof that a new -view should emit input immediately. - -- Cold restore into a fresh shell keeps the existing full mode reset. -- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only - bootstrap ownership. -- Snapshot serialization/replay must not mutate live ownership trackers. -- A snapshot containing an OSC query or an old Orca reply never produces a provider write. -- Model and renderer snapshots must both be free of matched cooked reply projections. - -Any future focus ownership metadata must be explicit; it cannot be inferred from serialized -`?1004h` text. - -## Failure Policy - -Safety is asymmetric: - -- This design does not intentionally suppress a focus notification; evidence of a focus-ownership - race triggers a follow-up design instead. -- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the - application. -- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate - replies are forbidden. -- Losing buffered output on timeout or teardown is forbidden. - -## Cross-Platform and Remote Scope - -- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility - path; focus investigation only until a proven root cause has its own complete design. -- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround. -- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the - remote host protocol later supplies explicit equivalent evidence. -- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there; - desktop local main does not reinterpret its stream. -- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query - response authority through the existing terminal-driver election. - -## Edge Cases - -- A query or echo split at any byte boundary, including OSC ST split across chunks. -- The first reply echo arriving before the second color query. -- Unrelated output, an exact application-text collision, or a partial match before the real echo. -- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held. -- A provider write causing a synchronous nested output callback. -- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing - with output. -- BEL-terminated queries still receiving the canonical ST reply. -- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers. -- Old daemon/relay protocol versions and sessions that survive an application upgrade. -- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds. -- Focus gained through normal terminal input versus the separate explicit reattach-focus write path. - -## Test Plan - -### Deterministic provider harness - -- Capture the native ConPTY bootstrap across natural and forced chunk boundaries. -- Reproduce the OSC reply echo with the real provider and an agent query fixture. -- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11 - queries and combined OSC 10 `?;?`. -- Force the first reply echo before the second query. -- Split every byte boundary in query and echo fixtures. -- Print the expected projection immediately before the real echo and assert that the first - identical candidate is removed while the later echo passes through. -- Exercise focus gain/loss through the actual xterm focus path. - -### Ingress integration - -- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive - classified data before storing it. -- Assert renderer and mobile delivery receive the same visible output. -- Assert raw start/end spans advance by original provider length, producer ACK contribution is - counted exactly once, and mobile/runtime metadata reports the raw span rather than string length. -- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move, - and teardown, including a prefix held from an earlier callback. -- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the - write. -- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather - than slicing cleaned text. -- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam; - assert `SshPtyProvider` does not reinterpret relay data. -- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection. -- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold - restore; assert capable relay replay/history has the same property. Assert old daemon and relay - versions are detected and never represented as sanitized. - -### Authority and restore - -- Assert an early-consumed query is answered once by its source owner and never by renderer or - hidden model. -- Assert normal delivered/dropped query authority resumes after startup state clears. -- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do - not contain cooked OSC text. -- Assert replay never sends OSC or focus replies. -- Assert ordinary native console focus behavior remains enabled. -- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any - later ownership implementation defines its tests in the required follow-up design. - -### End-to-end acceptance - -On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions. -Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the -focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops -at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus -behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain -PowerShell terminal and a native focus-event consumer to prove no global regression. - -Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint, -formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also -require the repository's SSH end-to-end procedure. - -## UI Quality Bar - -This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must -look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a -clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and -focus presentation. - -## Review Screenshots - -1. A fresh native-Windows agent prompt after startup, with no OSC reply text. -2. The same session after hide/reveal restore, still clean and without duplicated output. -3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state. -4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior. - -## Rollout - -1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress - state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready - preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay - `PtyHandler` before their models, persistence/replay, and fanout. -2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates, - carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query - authority documents with exact transfer events. -3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical - ST replies, consume answered queries for model and views, and retain raw sequence accounting. -4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO - anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage, - and authoritative daemon/model/snapshot tests. -5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop - for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed - only with a failing regression test that selects that branch. -6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL, - and SSH scenarios before removing the old startup implementation. - -Each slice must keep query authority singular. The compatibility projection does not ship without a -model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness -fails on the old behavior and passes on the new behavior. - -## Lightweight Eng Review - -- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global - focus filtering and an unproven ownership state machine remain out of scope. -- Architecture/data flow: classification belongs at each PTY source owner after shell-ready - preprocessing and before every authoritative model, persistence, replay, or delivery consumer. -- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes, - authority races, snapshot and teardown drains, protocol-version skew, reattach, and host - isolation. -- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay, - runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and - renderer-focus harnesses; Electron and SSH end-to-end validation. -- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one - startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery - path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory. -- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage - disappears, without cursor/focus regressions or restore flashes. -- Required review screenshots: the four terminal states in `Review Screenshots`. -- Residual risks: the native projection may be too unstable to enable; exact projected application - output can collide; `[I` may require a separately reviewed focus-ownership design. - -## Non-Goals - -- Replacing xterm's general query parser. -- Filtering arbitrary escape-looking terminal output. -- Disabling ConPTY focus or Win32 input mode globally. -- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows. -- Solving unrelated MCP startup warnings reported beside the terminal garbage. - -## Final Invariants - -1. Provider bytes are classified once before model ingestion and view delivery. -2. A live query has exactly one responder; replay has none. -3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched - startup-reply projection. -4. Raw provider sequence accounting survives sanitization. -5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data. -6. ConPTY bootstrap modes reach live emulators unchanged. -7. No focus suppression ships from this design; a proven ownership race requires a follow-up design. -8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state. diff --git a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md b/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md deleted file mode 100644 index 8a31a894b07..00000000000 --- a/docs/reference/plans/2026-07-21-windows-daemon-generation-safety-investigation.md +++ /dev/null @@ -1,620 +0,0 @@ -# Windows daemon-generation safety investigation (#9749) - -Investigation and completion snapshot: 2026-07-21 16:59 PDT -(2026-07-21 23:59 UTC) - -Branch baseline: `OrcaWin/issue-9749-windows-daemon-generation-safety` at -`937a2015e`, 40 commits after `v1.4.148-rc.1`. This document is the hard gate -before a reproduction harness or production change. GitHub access during the -investigation was read-only, and no installed Orca daemon, pipe, token, process, -or terminal session was contacted or changed. - -## Findings - -The reported incident is a composition of three lifecycle paths, not one: - -1. **Survival and adoption:** normal app quit deliberately disconnects from - daemon clients without shutting down their PTYs. Protocol-specific named - pipes let multiple generations coexist, and legacy adapters intentionally - reconnect to them so live PTYs remain warm and reattachable across upgrades. -2. **Broad current-generation replacement:** - `cleanupDaemonForProtocol` sends the only production - `shutdown { killSessions: true }` request found in the repository. Its - production callers replace or explicitly restart the current generation; - legacy discovery does not call it. A failed `listSessions` is currently - converted to an empty list before this broad shutdown, which makes its - reported kill count untrustworthy but does not suppress the shutdown. -3. **Reconnect-triggered per-session destruction:** #8871 provides the strongest - causal evidence. A reconnecting renderer restores stale remote handles, - synthesizes `pty-exit`, drops that reason at `session.tabs.close`, and the - authoritative host interprets the close as user intent. Its PTY router then - forwards individual `kill` requests to whichever current or legacy adapter - claimed each reusable session ID during discovery. - -`client-hello` is necessary to discover and route legacy sessions, but neither -the hello handler nor legacy discovery sends a kill or shutdown request. The -~3.3-second renderer-bootstrap-to-kill evidence in #8871, repeated renderer -spawn/burst correlations, and the untyped close path are substantially stronger -than #9749's inference that greeting an old pipe causes that daemon to kill its -table autonomously. - -The immediate safety boundary is therefore destructive request authority and -provenance. Cross-profile, sleeping-session, and generation-retirement policy -remains the larger #9138/#9229 problem. - -## Deterministic native-Windows reproduction - -Reproduction snapshot: 2026-07-21 14:36 PDT (2026-07-21 21:36 UTC). - -`tests/e2e/daemon-generation-reconnect-safety.spec.ts` now constructs an -isolated `orca-9749-dg-*` runtime under the Windows temporary directory. It -starts three real daemon-server processes on versioned v21/v22/v23 named -pipes, with a live canary and a stale-mirror canary in each generation. Each -canary records the PTY-root and descendant PID/start identity independently. -The fixture refuses cleanup outside its exact temporary root and terminates -only recorded fixture process incarnations; it never enumerates or connects to -installed Orca endpoints. - -The Electron-as-Node reconnect client performs three production -`DaemonPtyAdapter`/`DaemonPtyRouter.discoverLegacySessions` bursts, reattaches -every original PID, pings every canary, and opens a simultaneous second client -to each generation. It then sends duplicate `session.tabs.close` RPCs carrying -`reason: 'pty-exit'` through the real schema, `OrcaRuntimeService`, and routed -daemon adapters while the reconnect-client process remains alive. - -The current-main run failed at the intended external invariant: - -- before the close, all three daemons, six PTY roots, and six descendants were - alive; -- each daemon accepted eight new control/stream hellos across the reconnect and - parallel-client cycles; -- v21, v22, and v23 each logged two `session-killed` requests for the same - stale-mirror session ID within 1–4 ms; -- all three stale-mirror PTY roots and descendants exited, while all three - unrelated live canaries, all daemon processes, and reconnect client PID 26876 - remained alive; -- the final assertion requires those stale-mirror roots to remain alive, so it - is RED before the fix and will become GREEN only when lifecycle-originated - closes are adjudicated non-destructively at the host. - -Focused command: - -```text -pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 -``` - -The captured per-generation timestamp/PID/hello/kill/liveness report is written -to the Playwright test output as `daemon-generation-reconnect-events.json`. - -## Release and protocol chronology - -| Release or branch | Date | Daemon protocol | Relevant lifecycle behavior | -| --------------------- | ---------- | --------------: | -------------------------------------------------------------------------------------------------------------------------- | -| `v1.4.141` | 2026-07-14 | 21 | Legacy adoption already intentional; #8871 observed here. | -| `v1.4.142` | 2026-07-15 | 22 | New versioned endpoint; v21 can remain reattachable. | -| `v1.4.143` | 2026-07-16 | 22 | #9138 macOS accumulation reported. | -| `v1.4.144` | 2026-07-17 | 22 | #9195 Windows survival reported. | -| `v1.4.145` | 2026-07-18 | 22 | No generation-retirement change. | -| `v1.4.146` | 2026-07-19 | 23 | #9749's old current generation; already contains #8661's continue-shutdown-on-dispose-failure behavior. | -| `v1.4.147` | 2026-07-20 | 24 | First stable release containing #9277: authenticated identity and atomic empty-daemon retirement. | -| `v1.4.148` | 2026-07-21 | 24 | Reporter current release; legacy v23 and older remain intentionally adoptable. | -| `main` at `937a2015e` | 2026-07-21 | 25 | Protocol bumped by #9651 (`cc44acaaa`) for PTY startup ingress; v24 is now legacy. No stable tag contains this commit yet. | - -`PREVIOUS_DAEMON_PROTOCOL_VERSIONS` is cumulative rather than one-version-only. -At the investigated `main`, it contains versions 1 through 24. - -## Investigation matrix - -Confidence is **proven** when the issue evidence and source/diff establish the -mechanism, **supported** when multiple observations fit a reachable source path, -and **suspected** where attribution is missing. - -| Item | State; dates; releases/protocols | Exact symptom | Mechanism and relevant code/commits | Shipped status; unresolved #9749 relevance; scope boundary | -| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [#9749](https://github.com/stablyai/orca/issues/9749) | Open; 2026-07-21; Windows 11; daemon-host 1.4.146→1.4.148; old v21/v22/v23 and current v24 observed | Three surviving daemons were greeted at `16:41:32.277Z`–`.286Z`; later bursts logged 55/30/65/60/60 v22 kills and 10/32 v23 kills, including repeated IDs, while 191,266 main-process spans showed no coincident app death. One v23 broad `shutdown reason:rpc killSessions:true` and repeated `shutdown-dispose-failed` were present. | **Supported composition, reporter causality not proven.** Versioned legacy adoption explains hellos; `cleanupDaemonForProtocol` explains broad shutdown; #8871's stale-mirror close echo best explains reconnect-time individual kills. `daemon-server.ts` logs `session-killed` before awaiting `host.kill`, so repeated records prove repeated requests, not repeated physical death. | No fix shipped for the destructive initiator. #9277 mitigates empty v24+ accumulation only. Skipping all legacy hello or killing legacy generations on sight would destroy the promised warm-reattach behavior and is unsafe scope creep. | -| [#9138](https://github.com/stablyai/orca/issues/9138) | Open; 2026-07-17; macOS 1.4.143/v22; observed v18/v20/v21/v22 | UI showed 8 sessions while about 46 Claude processes remained; removing stale trees removed about 370 processes and reduced swap from 25 GB to under 5 GB. | **Proven accumulation, ownership policy deliberately conservative.** Normal disconnect, versioned endpoints, incomplete cross-profile ownership, sleeping/cold restore, and legacy wildcard claims prevent safe blind retirement. Prototype commit `7c915909b` implements an audit/journal/incarnation design but is unmerged. | No complete fix shipped. #9277 safely handles only empty current-generation v24+ daemons. The broader ownership journal, grace period, all-profile evidence, and enforcement rollout belong here, not the immediate #9749 close-authority patch. | -| [#9211](https://github.com/stablyai/orca/issues/9211) | Closed duplicate 2026-07-17; v21/v22 | A v21 daemon remained four days beside v22, and each app launch greeted both. | **Proven intentional legacy discovery with missing retirement.** `createLegacyDaemonAdapters` plus `discoverLegacySessions`. | Consolidated into #9138; no separate shipped fix. Same accumulation input as #9749, but not its destructive initiator. | -| [#9229](https://github.com/stablyai/orca/issues/9229) | Open P1; 2026-07-17; Linux headless | Failed shutdown left a 15.55 GiB/81-process old runtime beside a 1.21 GiB/4-process replacement. | **Supported reconciliation design.** Requires provenance, one destructive authority, exact daemon/session PID+start incarnations, complete evidence, and fail-closed missing reads. | Unshipped. Supplies invariants for later reconciliation. Implementing its full profile migration and journal in #9749 would be unsafe scope expansion. | -| [#9195](https://github.com/stablyai/orca/issues/9195) | Open; 2026-07-17; Windows 1.4.144/v22; comments through #9277 release read | `orca-terminal-daemon.exe` remains after app exit; later comments observed v21/v22/v23 and invisible sessions. | **Proven intentional for non-empty daemons.** Normal quit calls `disconnectDaemon`, not `shutdownDaemon`; the child is detached/unref'd. Empty accumulation was an unhandled gap before #9277. | #9277 shipped in 1.4.147 for empty v24+ only. Treating every survivor as a bug would regress warm reattachment. | -| [PR #9277](https://github.com/stablyai/orca/pull/9277) | Merged 2026-07-20 as `7adda25b0`; first stable `v1.4.147`; v23→v24 | Empty current-generation daemons accumulated after clean app disconnect or failed adoption. | **Proven by diff/tests.** Hello returns PID/start-time/launch-nonce identity; control/stream identity must match. `shutdownIfIdle` atomically checks one complete client, no unknown transport, no sessions, and no admission in flight. Shutdown fences admission before disposal. A two-minute watchdog covers only never-adopted startup. | Shipped in `v1.4.147-rc.4`, `v1.4.147`, `v1.4.148-rc.1`, and `v1.4.148`. It explicitly does not perform startup reaping, retire legacy daemons, infer ownership, or kill reattachable PTYs. Strong identity/capability foundation; partial mitigation only. | -| [#8871](https://github.com/stablyai/orca/issues/8871) | Open P0; 2026-07-15; first observed 1.4.141/v21; all corrections/comments read | First reconnect kill followed renderer bootstrap by 3.348 s. Three later fresh renderer processes correlated within about 1 s with bursts killing 4, 7, and 7 worktrees while app and daemon PIDs remained stable. | **Supported-to-proven request path.** Persisted remote handle → subscribe returns `no_connected_pty` → synthetic `pty-exit` → `closeTerminalTab(reason:'pty-exit')` → reason dropped from `session.tabs.close` → `closeMobileSessionTab` kills host PTY. Early pre-connect kills were correctly downgraded to unattributed. Close propagation intersects landed #8628 and later #8958. | Not fixed on main. This is the strongest immediate #9749 foundation. Trigger repair alone is insufficient; the authoritative host must adjudicate intent. | -| [PR #8872](https://github.com/stablyai/orca/pull/8872) | Open; 2026-07-15; head `bb69fac775`; rebased 2026-07-21 | Prevents mirror `pty-exit`/cleanup evidence from killing a host PTY while retaining real user close and dead-tab retirement. | **Actual diff/tests inspected.** Threads typed `user`/`pty-exit`/`cleanup`; host refuses non-user closes if any parent leaf has a connected PTY, republishes an unchanged snapshot with a guarded replay marker, and handles dead-leaf/live-sibling loops. Adds an optional daemon `kill intent:'auto'` guard, but intentionally sends no auto intent until protocol/capability negotiation exists. | Unshipped. The earlier Windows/WSL live matrix passed on an older head and was explicitly invalidated by the rebase, so native validation must be rerun. This is the best narrow safety foundation, but reasonless old clients remain destructive on a new host. | -| [PR #8888](https://github.com/stablyai/orca/pull/8888) | Open; 2026-07-15; head `b13235211`; reviews/follow-ups read | Ambiguous paired-runtime close RPCs could destroy host tabs with no requester attribution. | **Actual diff/tests inspected.** Default-denies intent-less paired-runtime `session.tabs.close`, `terminal.close`, and `terminal.closeTab`; validates user source/target, dedupes request IDs per device across reconnects, rate-limits, restricts create rollback to its connection, and traces device/connection/source/decision without bearer tokens. Adds local daemon-control client ID to `session-killed`. | Unshipped. Useful defense-in-depth and logging foundation. Its “old paired client gets successful no-op” behavior is safely conservative but needs explicit compatibility/product acceptance; taking the entire policy is broader than the smallest #9749 fix. | -| [#9414](https://github.com/stablyai/orca/issues/9414) | Open tracking issue; 2026-07-19 | Recognition-dependent terminal lifecycle actions can destroy or strand arbitrary processes. | **Proven design boundary.** Host owns adjudication; stale client transport state and agent-name recognition are not kill authority. Tracks #8872/#8888 and lifecycle-state work. | Unshipped tracker. Aligns with #9749 invariants; generic agent recognition work is separate scope. | -| [PR #8628](https://github.com/stablyai/orca/pull/8628) | Merged 2026-07-14 as `36cd8a334`; releases after that date | Tab close did not durably retire all associated terminal/session state. | **Actual merge diff inspected.** Centralized terminal retirement/ownership and ensured remote-owned closes reach the authoritative host. The remote call still carried no close reason, so lifecycle and user closes were indistinguishable. | Shipped and important context, not a regression to revert wholesale. #9749 must preserve its durable explicit-user close while adding authority. | -| [#8878](https://github.com/stablyai/orca/issues/8878) / [PR #9098](https://github.com/stablyai/orca/pull/9098) | Issue and PR open; 2026-07-15/17; PR head `9f3abd778` | A reconnecting paired client resumes a provider session still live on the host, producing duplicate TUIs. | **Proven companion feedback loop.** Client-local resume runs before authoritative mirror arrival; a #8871 kill also leaves a resume record. PR gates resume for runtime-owned worktrees while preserving the record. | Unshipped. Relevant reconnect stress case, but provider-session resume dedupe is not needed to stop #9749 kills. | -| [#9352](https://github.com/stablyai/orca/issues/9352) / [#9585](https://github.com/stablyai/orca/issues/9585) | Open; 2026-07-18/20; remote macOS and Windows | Closed or killed remote tabs return as `ptyId:null` phantoms; host snapshots keep dead terminal surfaces, and repeated host restarts accumulate them. | **Proven stale mirror/snapshot state.** Host `touchMobileSessionSnapshotsForPty` republishes the same tab; viewer mirrors every terminal surface. Remote transport also leaks the explicit-kill error. | Unshipped. Explains repeated stale close inputs and must be in stress coverage, but pruning dead surfaces is a separate lifecycle fix requiring sleeping-session care. | -| [#9217](https://github.com/stablyai/orca/issues/9217) / [#8970](https://github.com/stablyai/orca/issues/8970) | #9217 closed duplicate 2026-07-19; #8970 open; v1.4.143 | Agent/sidebar rows remain after local close or SSH relay connection loss. | **Proven UI/status lifecycle gaps.** SSH teardown intentionally preserves PTY ownership for reattach but omitted status clearing; renderer lacks a sweep. | Separate non-destructive roster cleanup. Combining sidebar cleanup with #9749 would be scope creep. | -| [#8851](https://github.com/stablyai/orca/issues/8851) / [PR #8825](https://github.com/stablyai/orca/pull/8825) | Issue closed; PR merged 2026-07-15 as `c8986ca52`; 1.4.143 notes | Finished named Claude children remained as idle sidebar rows. | **Proven Claude roster mechanism, not PTY lifecycle.** Working-only roster and hydration cleanup. | Shipped, but #8970 proves top-level/session residuals. Unrelated to destructive daemon authority. | -| [#8275](https://github.com/stablyai/orca/issues/8275) / [#8276](https://github.com/stablyai/orca/issues/8276) | #8275 open `cannot_repro`; #8276 closed duplicate; 2026-07-11; v20→v22 attempts | Rapid worktree removal was followed by daemon death and unrelated split panes exiting `-1`; no shutdown record. | **Different mechanism.** Shared daemon process dies during PTY teardown; current v22 attempt did not reproduce. | Not the #9749 initiator: #9749 daemons stay alive and log explicit shutdown/kill requests. Keep daemon-death regression coverage, but do not merge root causes. | -| [PR #8140](https://github.com/stablyai/orca/pull/8140) | Merged 2026-07-10 as `03a673708`/PR head `1710325e0`; fixes #8048 | Graceful then immediate Windows PTY teardown double-closed the same ConPTY handle and killed the shared daemon. | **Proven by actual native harness/diff.** `nodePtyKillIssued` makes later Windows force a no-op. Harness uses unique pipe/temp state, 25 victims, and a witness PTY/daemon survival assertion. | Shipped. Prevents one daemon-death path; does not authorize reconnect closes and does not retire descendants. Reuse its isolation/witness patterns only. | -| [PR #8284](https://github.com/stablyai/orca/pull/8284) | Closed unmerged 2026-07-16; head `ebe0b6523` | Proposed serial worktree PTY admission and verified fail-closed teardown across Windows/POSIX/SSH. | **Actual 50-file diff/tests inspected.** Retained shutdown ownership, admission fences, physical-exit proof, and Windows relay ConPTY ownership. | Contrary to the issue prompt, it did **not** merge. Landed work was split/superseded by #8661 and #8706. Its teardown patterns are useful, but adopting its broad branch is unsafe. | -| [PR #8661](https://github.com/stablyai/orca/pull/8661) | Merged before 1.4.146; key commit `a635ff9a7` | Disposal failure could prevent orderly runtime/daemon termination. | **Proven current source behavior.** Shutdown RPC catches `host.dispose`, logs `shutdown-dispose-failed`, then continues fencing, client destruction, and server close; resource disposal is retried. | Shipped before #9749. Corrects the report's inference that the failure necessarily leaves the endpoint authoritative forever. A native handle may still keep a process alive, but the server intends to close. | -| [#9045](https://github.com/stablyai/orca/issues/9045) | Open; 2026-07-16; Windows | Worktree deletion fails because agent descendants retain filesystem handles after PTY teardown. | **Supported descendant ownership gap.** Root PTY exit is not full tree exit on Windows. | Unresolved. It concerns cleanup after a legitimate kill, not who may initiate it. | -| [#9704](https://github.com/stablyai/orca/issues/9704) | Open; 2026-07-21; Windows 1.4.147/v24 | Killed PTY descendants survive (six trees/18 processes/~1.1 GB, later ~2 GB); runtime lists dead sessions as connected. | **Proven descendant leakage plus stale registry.** Explicitly asks for initiator attribution rather than assuming the kill was valid. | Unresolved. Different from #9749 because the root session was killed; #9704 concerns what survives afterward. | -| [PR #9752](https://github.com/stablyai/orca/pull/9752) | Open; 2026-07-21; head `6be947317` | Windows agent descendants survive explicit or natural PTY-root exit. | **Actual native patch and tests inspected.** Suspends recognized native-Windows agent ConPTY roots, creates/configures/assigns a kill-on-close Job Object before resume, owns the handle atomically, and falls back to direct-root termination. Plain terminals, WSL, POSIX, SSH relay, and unrelated sessions are excluded; Windows process-table commands are removed. | Unshipped. Correct post-authorization cleanup, explicitly not an initiator fix. Combining it into the #9749 authority patch would obscure causality; compose/test separately if it lands first. | -| [PR #9266](https://github.com/stablyai/orca/pull/9266) / [PR #9612](https://github.com/stablyai/orca/pull/9612) | Both open; 2026-07-18/20 | Alternative Windows descendant tree termination using process enumeration/taskkill-style sweeps. | **Competing cleanup approaches.** Carry PID-reuse, access-denied, cost, and partial-tree risks that native Job ownership avoids. | Unshipped. Do not duplicate inside #9749. | -| [PR #8706](https://github.com/stablyai/orca/pull/8706) | Merged 2026-07-15 as `40d015992`/merge `6dbeeda3e`; stable thereafter | POSIX agent descendants survived root teardown. | **Proven POSIX snapshot-before-root-kill.** Windows intentionally returns no snapshot. | Shipped for macOS/Linux only. No #9749 authority effect. | -| [#9193](https://github.com/stablyai/orca/issues/9193) / [PR #9288](https://github.com/stablyai/orca/pull/9288) | Both open; 2026-07-17/18 | `terminal close --tab` cannot address live floating/tabless PTYs; pane close can kill, and Windows may retain a stale entry. | **Proven addressing/registry gap.** PR routes tabless PTY through existing pane close. | Unshipped. Diagnostic and stale-registry relevance only; making more sessions closeable is not authority fencing. | -| [#9563](https://github.com/stablyai/orca/issues/9563) / [PR #9634](https://github.com/stablyai/orca/pull/9634) | Both open; 2026-07-20/21; headless macOS | LaunchAgent host invokes update, disconnects clients, old binary respawns, and ShipIt reports “App Still Running.” | **Supported updater/headless ownership race.** PR defers installation while serving headlessly. | Unshipped. Must be a relaunch scenario in the harness, but updater policy is separate from close authority. | -| [#8261](https://github.com/stablyai/orca/issues/8261) | Open; 2026-07-11; macOS/Linux headless comments | Silent update installation kills active PTYs; one headless log has `shutdown reason:rpc killSessions:true`. | **Supported broad shutdown during update.** | Unresolved adjacent caller/lifecycle context. Updater UX and install policy are scope creep; broad shutdown attribution is relevant. | -| [#8459](https://github.com/stablyai/orca/issues/8459) | Open; 2026-07-13 | Resource Manager labels live daemon sessions orphan from renderer-only evidence and bulk-kills them. | **Proven ownership-safety precedent.** Absence from one renderer is not authority. | Unshipped. Same invariant as #9749, different UI initiator. Do not couple UI resource-manager redesign. | -| [#8585](https://github.com/stablyai/orca/issues/8585) | Open; 2026-07-13; SSH relay | Failed relay `--connect` unlinks a socket while the old relay and PTYs remain alive. | **Different namespace/transport ownership leak.** | Unresolved. SSH regression consideration, not a native daemon-generation fix. | -| [#7783](https://github.com/stablyai/orca/issues/7783) | Open; 2026-07-08; macOS | Helper survives app quit with roughly 189 descendants. | **Supported historical survival/descendant leak.** | Unresolved adjacent accumulation evidence; no reconnect-kill attribution. | -| [#8457](https://github.com/stablyai/orca/issues/8457) | Open; 2026-07-13 | Headless serve and GUI relaunch ownership collide, interrupting or duplicating live agents. | **Supported multi-owner lifecycle conflict.** | Reinforces exactly-one reconciliation authority. Full headless lifecycle redesign is separate. | -| [#8362](https://github.com/stablyai/orca/issues/8362) | Open; 2026-07-12; remote relay | PTY master FDs leak across relay children. | **Different mechanism: missing close-on-exec/inherited descriptors.** | Unresolved, but unrelated to named-pipe discovery or destructive requests. | -| [#9569](https://github.com/stablyai/orca/issues/9569) / [PR #9587](https://github.com/stablyai/orca/pull/9587) | Issue closed 2026-07-20; PR open | Worktree removal dials a dead legacy v22 socket after v23 upgrade and fails ENOENT. | **Proven stale adapter routing.** PR tolerates dead legacy adapter teardown. | Unshipped. Demonstrates adapter lifecycle staleness; no authority fix. | -| [#8689](https://github.com/stablyai/orca/issues/8689) / [PR #8697](https://github.com/stablyai/orca/pull/8697) | Closed/merged 2026-07-14; merge `840d3277d` | Daemon accepts a connection but never answers hello, wedging startup. | **Proven bounded handshake/replacement path.** | Shipped. Harness must bound hello and avoid reconnect storms; not a session-kill mechanism. | -| [PR #7538](https://github.com/stablyai/orca/pull/7538) | Merged 2026-07-07 as `03cfc5bd1` | Windows update moved daemon code while live daemon/PTYs should survive. | **Proven historical compatibility intent.** Relocated host preserves same-protocol daemon across update. | Shipped. Later legacy adapters extended preservation across protocol bumps; blanket reaping would regress this contract. | -| [PR #2974](https://github.com/stablyai/orca/pull/2974) | Merged 2026-05-28 as `5a852415a` | Resolver refresh risked killing live PTYs. | **Proven preserve-live policy.** Protocol bump/legacy routing is preferred to broad cleanup. | Shipped. Strong evidence against “kill old generation on sight.” | -| [PR #7836](https://github.com/stablyai/orca/pull/7836) | Merged 2026-07-18 as `5f6728c1b` | Shutdown/provider selection race could clear a binding while the daemon PTY survived. | **Proven ownership race fix.** Retains provider/shutdown ownership until outcome. | Shipped. Preserve in regression tests; not close-intent adjudication. | -| [PR #1343](https://github.com/stablyai/orca/pull/1343) | Merged 2026-05-03 as `df1fefcc2` | Users lacked session visibility; stale PID files risked PID-reuse mistakes. | **Proven management/PID-start-time guard.** | Shipped. Useful diagnostic/incarnation precedent, but observability alone cannot prevent #9749. | -| [PR #9516](https://github.com/stablyai/orca/pull/9516) | Merged 2026-07-20 as `2e67af82d`/`de86f482c` | Windows worktree teardown RPCs could hang indefinitely. | **Proven bounded-deadline change.** | Shipped. Bounds cleanup but does not decide whether cleanup is authorized. | -| [PR #8768](https://github.com/stablyai/orca/pull/8768) / [PR #8817](https://github.com/stablyai/orca/pull/8817) | Merged 2026-07-14/15 as `02de3c565` and `f7926c11f` | Restored/legacy PTYs could render blank or be unmounted while still live. | **Proven adoption compatibility.** Keep legacy daemon PTYs mounted and defer snapshots correctly. | Shipped. Direct reason that refusing all legacy discovery is unsafe. | -| [#9441](https://github.com/stablyai/orca/issues/9441) / [PR #9446](https://github.com/stablyai/orca/pull/9446) | Open; 2026-07-19; macOS 1.4.146 | Large persisted profile drives high CPU/RSS and exits during startup restoration; clean user-data does not reproduce. | **Supported restore-load ordering issue.** PR defers full worktree scan. | Unshipped. Profile-switch/load stress case only; not destructive daemon authority. | - -## Timestamped incident and call-flow reconstruction - -### A. How generations survive - -1. **2026-07-15 13:50 local:** #9749's v21 daemon starts. -2. An app quit/update runs `disconnectDaemon`, whose adapter `disconnectOnly` - closes client sockets and leaves live PTYs/history reattachable. The daemon is - detached/unref'd, so parent death is not daemon death. -3. **2026-07-18 14:26 local:** a v22 daemon starts on a different versioned - endpoint while v21 retains its PTYs. -4. **2026-07-20 14:50 local:** v23 starts while both prior endpoints remain. -5. Windows endpoints are generated by - `getDaemonSocketPath(runtimeDir, protocolVersion)` as - `\\?\pipe\orca-terminal-host-v-`. - Tokens and PID records are likewise protocol-specific files. No endpoint - collision forces an old generation out. -6. Before protocol 24, a non-empty daemon had no generation-retirement - protocol. Since #9277, an empty v24+ daemon can atomically self-retire, but a - live session intentionally blocks it and v23-or-older behavior is unchanged. -7. #9749's claim that `shutdown-dispose-failed` necessarily leaves the pipe open - is not source-proven for 1.4.146. Commit `a635ff9a7` catches disposal failure - and continues ordinary shutdown; #9277 additionally closes admission first. - A stuck native handle may keep a process alive, but the endpoint is meant to - stop being authoritative. - -### B. Why startup greets every surviving generation - -1. Current startup establishes the v25 (v24 in the reporter build) adapter and - its complete control/stream lifecycle lease. -2. `createLegacyDaemonAdapters(runtimeDir)` loops every value in - `PREVIOUS_DAEMON_PROTOCOL_VERSIONS`, derives that version's pipe/token/PID - paths, and probes each endpoint. -3. A responsive endpoint receives a `DaemonPtyAdapter` configured with that - exact old protocol. It has no respawn callback, because new code must not - recreate old environment semantics. -4. `DaemonPtyRouter.discoverLegacySessions()` calls `adapter.listProcesses()` - for each legacy adapter. -5. `listProcesses()` calls `ensureConnected()`. `DaemonClient.doConnect()` opens - a control socket, sends hello, then opens a stream socket and sends hello - using one client UUID. This is the precise source of each control/stream pair - in #9749 at `16:41:32.277Z` through `.286Z`. -6. v24+ hellos return PID/start-time/launch-nonce and require both sockets to - match. Old protocols return no identity, so successful token+protocol hello - authenticates the endpoint but cannot prove a process incarnation. -7. `listSessions` results populate `sessionAdapters: Map`. - That map is keyed only by reusable session ID, not daemon/session - incarnation; a later generation can overwrite an earlier claim. -8. No code in hello acceptance, adapter construction, or discovery sends - `kill`, `shutdown`, or `shutdownIfIdle` to a legacy daemon. - -### C. Exact broad shutdown caller - -1. Current-daemon replacement/manual restart/full cleanup calls - `cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)` (or the explicitly - supplied current handle protocol). -2. It probes that version's endpoint and creates a `DaemonClient` for the same - protocol. -3. It connects with the same control/stream hello sequence. -4. It requests `listSessions`; any error is converted to `{ sessions: [] }`. -5. It then unconditionally sends `shutdown { killSessions: true }` and treats a - reply race with daemon exit as success. -6. `daemon-server.ts` logs `shutdown reason:'rpc' killSessions:true`, begins the - ordinary shutdown admission fence, awaits `host.dispose`, logs but catches - `shutdown-dispose-failed`, writes the reply if possible, disposes resources, - destroys clients/transports, unlinks owned identity artifacts, and closes - the server. -7. Repository search found no other direct production sender of - `shutdown { killSessions: true }`. Legacy adapter discovery is not a caller. -8. Unsafe residual: list failure cannot be treated as proof of emptiness for - any future generation reaper, and broad shutdown still lacks an origin, - daemon-incarnation, ownership, and intent audit record. - -### D. Supported reconnect-to-mass-kill path - -1. **Renderer bootstrap T+0:** a paired desktop/runtime renderer hydrates - persisted mirrors containing process-lifetime remote terminal handles. -2. **T+milliseconds:** before the fresh authoritative session snapshot fully - reconciles, remote transport subscribes using a stale handle. -3. The host cannot resolve a connected PTY for that handle and returns a gone - condition; remote transport synthesizes `pty-exit`. -4. `Terminal.tsx` calls `closeTerminalTab(tabId, { reason: 'pty-exit' })`. -5. Current main uses that reason for local retirement behavior but - `closeWebRuntimeSessionTab` sends only `{ worktree, tabId }`. -6. Runtime RPC `session.tabs.close` has no origin/intent in its schema or - handler. `OrcaRuntimeService.closeMobileSessionTab` treats it like an - explicit user request and invokes either direct `ptyController.kill`, a - whole-parent renderer close, or headless teardown. -7. For daemon-backed terminals, the PTY controller is the `DaemonPtyRouter`. - `adapterFor(sessionId)` uses the discovery map to select a current or legacy - generation and its adapter sends `kill { sessionId, immediate }`. -8. The owning daemon logs `session-killed` before awaiting `host.kill`. This is - why app and daemon PIDs remain alive while real terminal sessions disappear. -9. **#8871 observed T+3.348 s** for the first bootstrap-to-kill incident. Three - other new renderer processes aligned within about one second with 4-, 7-, - and 7-worktree kill bursts. -10. Stale host tab surfaces (#9352/#9585), persisted client mirrors, reconnect - retries, and republished snapshots can invoke the same close again. If the - daemon still retains the session entry or physical exit is unresolved, a - reconnect can rediscover and target the same ID again. Because logging - precedes the awaited kill, repeated `session-killed` records can also be - repeated failed/not-yet-settled attempts; they do not prove a dead process - was resurrected and killed twice. - -### E. Why adjacent bugs are different - -- **#8275/#8048/#8140:** PTY teardown double-closes a Windows ConPTY native - handle and the shared daemon dies. There is no broad shutdown event. All - sessions disappear because their owner process died. -- **#9704/#9045/#9752:** a valid or invalid kill has already targeted the PTY - root, but Windows descendants remain and retain memory/files. The owner stays - alive or the registry stays stale. This is cleanup completeness after a kill, - not destructive authority. -- **#9749/#8871:** app and daemons remain alive. Explicit RPCs reach live daemon - sessions because stale client lifecycle evidence is interpreted as intent. - -## Compatibility behavior that must remain - -- App quit is a disconnect, not terminal shutdown. -- Same-protocol daemon survival across packaged Windows updates (#7538) avoids - terminating live work. -- Old-protocol adapters remain addressable after an upgrade so mounted and - sleeping PTYs can reattach (#2974, #8768, #8817). -- Legacy daemons cannot be judged empty from one current profile or renderer. - Missing ownership data, inactive profiles, sleeping sessions, remote/SSH - routes, and legacy claims all mean keep/audit. -- Current protocol 24+ can retire only after its own atomic server-side idle - predicate proves there is nothing to preserve. -- Older clients/servers must not infer support from an ignored additive field. - In particular, no caller may send daemon `kill intent:'auto'` until a - protocol bump or negotiated capability proves the daemon will enforce it. - -## Direction comparison - -| Direction | Immediate safety | Compatibility and failure mode | Decision | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| Skip `client-hello` for every old protocol | Avoids routing kills to legacy sessions, but also makes their live PTYs invisible/unattachable. | Directly violates warm legacy reattachment and converts preservation into inaccessible leaks. | Reject. Hello is not the authority bug. | -| Force-exit on `shutdown-dispose-failed` | Bounds a half-shutdown process if endpoint fencing and handle cleanup are correct. | Can silently destroy promised live PTYs; does not stop reconnect close RPCs; overlaps #9752 descendant semantics. Current source already continues server shutdown after disposal error. | Not the immediate fix. Test bounded endpoint loss separately. | -| Startup reaping | Can reduce accumulated attack surface. | Unsafe without #9138/#9229 all-profile evidence, exact incarnations, grace observations, barriers, and one authority. | Audit-only follow-up, not #9749 patch. | -| Explicit generation handoff/retirement | Correct long-term lifecycle model. | Requires old/new capability negotiation and ownership persistence across profiles, SSH, WSL, and sleeping sessions. | Broader #9138/#9229 work. | -| Daemon-side fencing | Prevents a retiring daemon from accepting new work or acting after retirement begins. | v24 #9277 already fences ordinary/idle shutdown admission; older protocols cannot be retrofitted. | Preserve and extend only with negotiated capability. | -| Host close-intent adjudication (#8872) | Stops the demonstrated stale `pty-exit` echo at the component that owns the live PTY, while real user close and genuinely dead tab retirement still work. | Old clients remain ambiguous unless paired with a default-deny compatibility policy. | Best narrow foundation. | -| Connection/device provenance policy (#8888) | Default-denies ambiguous paired-runtime destruction, adds dedupe/rate bounds and strong attribution. | Older paired clients receive a safe successful no-op; policy is broader than one close reason and must preserve mobile/CLI/local/SSH semantics. | Reuse focused policy/logging pieces; assess full policy after the failing harness. | -| Job Objects (#9752) | Reaps descendants after an authorized native-Windows agent PTY kill. | Cannot determine intent; plain terminal and WSL exclusions are semantically important. | Compose separately, never substitute for authority. | - -The smallest robust immediate change is expected to combine typed close intent -with host-side liveness adjudication and non-secret requester logging. The -three-generation harness must decide whether #8888's default-deny/dedupe layer -is also required for old-client and repeated-reconnect safety. Full generation -retirement stays in #9138/#9229. - -## Harness gate for the next phase - -The first executable artifact will be an isolated native-Windows harness, not a -production edit. It must: - -- allocate a temporary runtime/user-data root and unique pipe namespace; -- launch three disposable protocol fixtures (for example v21/v22/v23) with - distinct live canary children plus stale/terminating table entries; -- use the real `DaemonClient`, legacy adapter discovery, router, and runtime - session-close path where feasible instead of calling a proposed helper; -- prove hello/discovery alone is non-destructive; -- separately trigger the exact broad current-protocol shutdown caller and the - reconnect stale-mirror close path; -- distinguish daemon/app liveness, PTY-root liveness, and descendant liveness - with independent counters and process handles; -- repeat reconnect bursts and record duplicate requests for a retained ID; -- model a PTY owner whose disposal never proves physical exit and assert a - bounded, non-authoritative endpoint state; -- cover current→next protocol upgrade, same-version quit/relaunch, profile - switch, remote-server paired client, multiple simultaneous clients, mixed - client/server versions, and WSL/SSH routing boundaries; -- validate PID/start identity, dead parent, access-denied identity probes, no - listener/pipe/handle leaks, and no CIM/PowerShell per-session hot path; -- guarantee cleanup in `finally` using only fixture-owned exact PIDs, handles, - files, and pipe names. - -Externally visible RED invariants are: reconnect kills one or more canary PTYs -while the app/fixture processes remain alive; repeated stale input can issue -another destructive request for the same retained incarnation; and a refused -disposal does not reach a bounded terminal endpoint state. The post-fix GREEN -invariants are survival of every unrelated canary, one exact kill for an -explicit user target, legacy warm reattachment, bounded retirement fencing, and -zero leaked fixture processes/handles/pipes. - -## Implemented immediate safety boundary - -Implementation snapshot: 2026-07-21 15:26 PDT (2026-07-21 22:26 UTC). - -The initial implementation used a 30-second capability cache keyed only by -runtime-environment ID. That was rejected before finalization: a positive -`status.get` result could outlive a server replacement under the same -environment, and the later destructive request had no proof that it reached the -generation that supplied the capability. - -The implemented boundary makes compatibility and identity validation atomic -with the lifecycle request: - -1. Explicit user closes remain on `session.tabs.close`, preserving the durable - host-owned close behavior shipped by #8628 and compatibility with old - servers that ignore the additive `reason:'user'` field. -2. Renderer-originated `pty-exit` and `cleanup` echoes use the additive - `session.tabs.closeLifecycle` method. An old server returns - `method_not_found` before entering its legacy destructive close handler. The - client clears optimistic close suppression and requests an authoritative - snapshot; it never falls back to `session.tabs.close`. -3. The lifecycle method requires both the `publicationEpoch` observed in the - host snapshot and the exact terminal handle observed for that tab. Missing - evidence means keep, refresh, and audit rather than kill. -4. The host refreshes its PTY records, rejects a different publication epoch, - rejects a terminal handle that no longer belongs to the addressed parent, - rejects when provider liveness is unavailable, and rejects while any parent - leaf still has a connected PTY. Refusals carry a bounded reason - (`stale-publication`, `stale-terminal`, `unknown-liveness`, - `live-host-pty`, or `retirement-owner`) and republish only when doing so - cannot create the known dead-leaf/live-sibling echo loop. -5. A lifecycle close never signals a PTY or relays a renderer close. A dead - whole headless parent can be retired from persisted/runtime state with - `killPtys:false`; a renderer-owned parent or partial split remains with its - authoritative owner. Thus a reusable tab ID or incomplete provider read - cannot become destructive authority. -6. Reasonless closes from authenticated legacy mobile or runtime clients retain - their pre-change explicit-user meaning, so upgrading only the host does not - break close. Their old lifecycle/user ambiguity remains until the client - upgrades; unattributed in-process reasonless calls are refused and replayed. -7. Renderer close intents are scoped by runtime environment and worktree, and - terminal-incarnation evidence must match that exact runtime environment. - Identical tab/worktree IDs in another profile cannot suppress or authorize - this profile's retirement. - -The RPC span records origin/client kind, close reason, connection ID, request -ID, publication epoch, and allow/refusal decision. It does not record terminal -contents, authentication tokens, or environment secrets. The legacy adapter -hello/discovery path, warm PTY adoption, daemon shutdown protocol, and Job -Object descendant cleanup remain unchanged. - -This is the smallest immediate #9749 fix. Cross-profile generation inventory, -all-profile ownership evidence, sleeping-session policy, and explicit daemon -handoff/retirement remain #9138/#9229. Windows descendant reaping after an -authorized kill remains #9704/#9752. - -## GREEN evidence - -The post-fix native-Windows run completed at 2026-07-21 15:25 PDT: - -- v21/v22/v23 used distinct versioned named pipes under a disposable runtime - root, with six independent PTY-root/descendant canaries; -- three reconnect discovery bursts plus simultaneous clients opened the real - control/stream hello pairs without destructive side effects; -- desktop and two remote/profile connection identities attempted each stale - mirror retirement three times, then a full reconnect-client process - exit/relaunch repeated the same persisted IDs for six attempts total; -- the lifecycle requests traversed the production schema, dispatcher, - `OrcaRuntimeService`, and `DaemonPtyRouter` using publication/terminal - incarnation claims; -- every daemon, PTY root, and descendant remained alive while the app/client - process remained alive; -- the contained refusal-to-exit PTY produced `shutdown-dispose-failed`, lost - named-pipe authority within the bounded deadline, and was cleaned by exact - fixture-owned process identity; -- the reconnect/relaunch test passed in 42.9 seconds and the bounded-disposal - test passed in 15.2 seconds. - -Focused verification at this checkpoint: node and web typechecks passed; -changed-file `oxlint` and `git diff --check` passed; 181 focused renderer/RPC -tests passed; and 12 host close-adjudication tests passed (784 unrelated tests -filtered out). No PowerShell/CIM process-per-session path, polling loop, broad -installed-daemon discovery, or Job Object implementation was added. - -Additional validation completed at 2026-07-21 15:48 PDT: - -- the native generation harness passed 25 reconnect bursts in 52.1 seconds; - every v21/v22/v23 daemon and all six PTY-root/descendant canaries survived; -- the mixed-version daemon-lifecycle E2E kept the non-empty v22 daemon - reattachable while the empty current v24 daemon retired through #9277; -- a fresh `pnpm build:electron-vite` and a fresh paired-web-client build both - completed successfully; -- 182 remote-runtime, multi-client, remote-server parity, SSH-provider, WSL - host-context, and remote PTY transport tests passed; -- the complete runtime service/RPC group passed 884 of 885 tests. The lone - failure, `preserves existing badgeColor on runtime createRepo dedupe`, is an - existing Windows-only POSIX path expectation (`/tmp/...` versus - `\\tmp\\...`) in code untouched by this change; every close, remote, SSH, - and WSL case in that run passed; -- CLI typecheck, switch-independent changed-file lint, reliability gates, - max-lines ratchet, and `git diff --check` passed. The repository-wide - switch-exhaustiveness command is blocked by the pre-existing unmatched - `undefined | 'current' | 'duplicate'` cases in - `skill-freshness-group.tsx`, outside this diff. - -Two practical Electron runs exposed setup/teardown limitations without -contradicting the close-safety result: - -- `restart-restore-terminal-input.spec.ts` completed the clean-restart, live - daemon, restored-output, keyboard-input, and direct-input assertions, then - failed only in `RestartSession.dispose()` with `EPERM` deleting its isolated - profile. Restart Manager and Sysinternals Handle found no surviving file - lock after teardown; the exact fixture root deleted successfully later - without terminating a process. The same failure reproduced with the temp - root inside this worktree and with the production diff to - `orca-restart.ts` empty, so it is recorded as a fixture cleanup limitation, - not a session-liveness failure; -- the paired-browser navigation E2E built the web client and launched the - isolated desktop, but timed out before pairing because its host fixture - displayed `No workspaces found`. The deterministic multi-client runtime - integration passed; no terminal-close assertion failed in this E2E. - -All diagnostic downloads and worktree-local E2E temp roots were removed by -their exact verified paths. No installed Orca daemon pipe or real user terminal -was discovered, greeted, stopped, or mutated during these runs. - -## Internal review-until-clean and final native evidence - -The requested `$internal-review-until-clean` loop ran against merge base -`937a2015eaf85144d02848c5b6d4c09ecd423830`. Round 1 found and fixed four -in-scope safety defects: - -- lifecycle retirement could still relay a destructive renderer close or kill - retained/disconnected headless IDs; lifecycle requests now never signal a - process and only state-retire a dead whole headless parent; -- an unavailable/access-denied PTY inventory was treated like authoritative - absence; it now returns `unknown-liveness` and keeps/audits; -- pending close intent was keyed only by worktree and could cross-contaminate - two runtime profiles; it is now scoped and cleaned by environment/worktree; -- the harness accepted a two-second PID start-time tolerance; capture and - revalidation now compare the same CIM `CreationDate` exactly. - -The elegance pass also removed an unnecessary cached capability probe. The -additive method dispatch on the exact connection is the atomic compatibility -boundary; a cached positive result could outlive server replacement. The -performance pass found no production polling, process enumeration, listener, -or subprocess addition. The existing bounded controller inventory refresh is -unchanged in frequency, snapshot refreshes coalesce per environment/worktree, -and all added tracking maps have completion or ownership cleanup. Round 2 -re-interrogated the full diff and found no remaining proven in-scope issue. -After strengthening the native harness to invoke production desktop discovery, -round 3 found one Windows-only test defect: three scanner tests modeled a live -v9 endpoint through POSIX `existsSync` but let every Windows named-pipe probe -connect. Their socket mock now accepts only v9 and errors every other version; -all 133 daemon lifecycle tests pass. The subsequent full-diff review is clean. - -The latest native Windows run captured its event reconstruction at -2026-07-21 16:58 PDT (2026-07-21 23:58 UTC). It launched v21/v22/v23/v24/v25 -on isolated versioned named pipes, then called the same -`createLegacyDaemonAdapters` scanner as desktop startup: the v25 client found -exactly v21-v24. Every generation accepted 16 reconnect control/stream hellos; -desktop and two remote-profile paths sent six lifecycle attempts for each -persisted stale-mirror ID across process relaunch. All five daemons, all ten -PTY roots, and all ten descendants were alive afterward, with zero -`session-killed` events. This directly covers the v24→v25 current-to-next -upgrade boundary while keeping older generations reattachable. - -The bounded `shutdown-dispose-failed` scenario also passed and now separates -authority from liveness explicitly: the late connection failed after endpoint -fencing while the refusing daemon, PTY root, and descendant remained alive -until exact fixture cleanup. The full Playwright command, including a fresh -Electron E2E build, exited successfully in 137.1 seconds, and no -`orca-9749-dg-*` directory remained. A final no-rebuild rerun passed both -scenarios in 99.1 seconds; the practical Electron clean-relaunch check passed -again in 24.0 seconds, and mixed-version retirement/live-session preservation -passed 2/2 in 23.1 seconds. - -Latest focused verification includes 15/15 host adjudication tests, 948/949 -focused production/RPC/renderer tests, 133/133 daemon discovery/adoption/ -retirement/access-failure tests, all three typechecks, and 215 -remote-runtime, multi-client, remote-server, SSH-provider, WSL-context, remote -PTY, and shared-control tests. The sole focused failure remains the untouched -Windows `/tmp` normalization baseline documented above. The experimental -`terminal-session.daemon-generation-reconnect-safety` reliability gate records -the invariant, RED/GREEN oracle, performance budget, promotion criteria, and -known platform/provider gaps. - -## Fresh PR review after current-main integration - -The explicitly requested post-PR `$internal-review-until-clean` pass completed -at 2026-07-21 18:02 PDT after merging `v1.4.150-rc.0` main, including the -remote-runtime network-recovery work from #9774. It found and fixed three -additional contract gaps: - -- a stale PTY-exit callback could borrow a replacement or sibling handle from - tab-wide state; lifecycle evidence now comes only from the exact callback PTY; -- the legacy close endpoint accepted lifecycle reasons without incarnation - evidence; it now accepts only explicit user intent, with reasonless - compatibility retained for authenticated legacy mobile and runtime clients; -- keep-on-unknown preserved the PTY but could leave its client mirror hidden; - the host now republishes unchanged authority when inventory is unavailable. - -The PR feedback loop also corrected the bulk-close payload assertion and made -the fixture protocol list collision-safe. The final native no-build run passed -both scenarios in 98.8 seconds, the fresh-build run passed in 137.1 seconds, -mixed-version retirement passed, and practical Electron restart/input passed -two scenarios with one intentionally skipped wedge scenario. Current-main -remote recovery (112 tests), focused close/reconnect suites, all 15 host -adjudication cases, all typechecks, reliability gates, max-lines, changed lint, -formatting, and diff checks pass. The full lint command remains blocked only by -pre-existing current-main switch-exhaustiveness and localization findings; no -remaining in-scope review finding is open. - -## Additional clean review after `v1.4.150-rc.0` integration - -This additional requested `$internal-review-until-clean` pass completed at -2026-07-21 18:56 PDT (2026-07-22 01:56 UTC) against merge base -`4d0e3f51ce0325a8f4670b4074618494984ab63d`. Round 1 found and fixed four -in-scope evidence/performance gaps: - -- the native harness omitted production `DaemonPtyRouter.listProcesses`, so - its synthetic lifecycle closes could pass through `unknown-liveness`; it now - routes the production inventory, uses worktree-prefixed daemon session IDs, - and requires every close to return `live-host-pty` with a republished - snapshot; -- concurrent reconnect closes each started a full cross-generation PTY - inventory; the host now shares one in-flight inventory and a deterministic - count test proves two concurrent closes call `listProcesses` once; -- the renderer introduced a second snapshot-refresh map; it now reuses the - existing environment/worktree remote-session deduper used by PTY reconnect; -- the reliability gate omitted the parked-tab callback path; its exact exiting - PTY incarnation assertion is now part of the gate. - -The review also added a wire-compatibility assertion proving that the additive -`{ reason: 'user' }` field is stripped by the previous `ActivateTab` server -schema. The repeated elegance and performance interrogation found no remaining -avoidable infrastructure, polling, subprocess churn, listener/handle leak, or -unbounded reconnect work. Round 2 re-read the full production and fixture diff, -constructed stale-publication, stale-handle, concurrent-close, mixed-client, -and old-server failure paths, and found no remaining proven in-scope issue. - -Fresh verification includes all three typechecks; reliability-manifest, -max-lines, changed-file lint, formatting, and diff checks; 175 remote snapshot -and PTY transport tests; and the isolated lifecycle/reconnect adjudication -group. The strengthened native five-generation Windows harness passed after a -fresh Electron E2E build in 137.2 seconds, required `live-host-pty` for every -synthetic close, left every daemon/root/descendant alive, and left no -`orca-9749-dg-*` directory. The full focused gate reached 992/993 passing tests; -the only failure remains the untouched Windows `/tmp` versus `\\tmp` -`createRepo` baseline. The newly merged headless-update group reached 143/144 -passing tests plus 25 skips; its only failure is an untouched LF-only -source-text assertion that does not match CRLF on Windows. - -## Completion evidence matrix - -| Original acceptance requirement | Authoritative evidence | Status | -| ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| Complete recursive issue/PR/commit/tag investigation before production edits | The 41-row investigation matrix above, release chronology, and timestamped call-flow reconstruction cover every required starting item and every directly discovered related item. GitHub reads were completed before the first production edit. | Proven | -| Explain survival, generation discovery/hello, broad shutdown caller, repeated reconnect kills, adjacent-bug distinctions, and compatibility | Sections A–E identify `disconnectDaemon`, `createLegacyDaemonAdapters` → `discoverLegacySessions` → control/stream hello, `cleanupDaemonForProtocol`, the stale-handle `pty-exit` path, #8275 versus #9704, and the warm legacy reattach contract. | Proven | -| Isolated native-Windows multi-generation reproduction with external liveness oracle | `daemon-generation-reconnect-safety.spec.ts` uses an exact temporary user-data/runtime root, versioned pipe names, disposable ConPTY children, independent daemon/root/descendant PID-start identities, bounded output, and exact cleanup. The historical RED run above recorded real repeated `session-killed` events while daemon/client witnesses stayed alive. | Proven | -| Exercise the desktop discovery path and current→next mixed-version upgrade | The latest native run calls production `createLegacyDaemonAdapters`; v25 discovers exactly v21/v22/v23/v24, reattaches every original process incarnation, and leaves all five generations alive. | Proven | -| Reconnect bursts, app quit/relaunch, simultaneous clients, repeated IDs, profile and remote-runtime boundaries | Three router rebuilds, a full reconnect-client process exit/relaunch, parallel direct clients, and six desktop/two-profile lifecycle attempts per persisted ID are native. Environment-scoped close-intent and remote-runtime transport suites supply deterministic profile-switch/remote-server boundary proof. | Proven (native transport plus deterministic profile/provider boundaries) | -| `shutdown-dispose-failed` has a bounded non-authoritative state without conflating process death | The native refusal fixture loses pipe authority within the deadline, rejects a late client, logs the failure, proves its daemon/root/descendant still live, then cleans only exact recorded fixture incarnations. | Proven | -| Smallest immediate fix preserves legacy adoption and separates broader retirement/descendant cleanup | Additive `session.tabs.closeLifecycle`, host liveness/incarnation adjudication, no destructive fallback, and state-only dead-headless retirement leave hello/adoption, #9138/#9229 retirement, and #9704/#9752 descendant semantics unchanged. | Proven | -| Missing evidence keeps/audits; retirement is incarnation/profile safe; one owner has destructive authority | Host tests cover unavailable inventory, stale publication/handle, live split siblings, renderer ownership, and authenticated legacy versus unattributed reasonless callers. Renderer tests cover exact environment handles and cross-profile intent isolation. Lifecycle requests never signal a PTY or relay renderer teardown. | Proven | -| Windows identity, dead-parent/never-adopted, ACL/access failure, rapid reconnect, and multi-client behavior | Native CIM `CreationDate` identity is exact; the five-generation run covers rapid reconnect and concurrent clients. The 133-test daemon group covers never-adopted retirement, admission fencing, overlapping clients, and EACCES/EPERM process-signal failures. | Proven (native identity/reconnect; deterministic ACL failure) | -| No production PowerShell/CIM hot path, polling/listener/handle leak, or reconnect storm | Process enumeration exists only in fixture helpers; production adds no subprocess or timer. Refreshes coalesce by environment/worktree, listener ownership is unchanged, every fixture allocation has bounded cleanup, and 25-burst stress evidence is recorded above. | Proven | -| Cross-platform, SSH, WSL, remote-server, and multiple-client compatibility | 215 deterministic tests cover remote runtime/server, shared control, SSH provider, WSL host context, and PTY transport. Platform-specific fixture behavior is runtime-gated. | Deterministic proof complete; live Linux SSH/WSL unavailable | -| Practical Electron restart behavior | A real isolated Electron application created a daemon-backed terminal, wrote and restored output across clean app quit/relaunch, preserved the exact daemon PID, and accepted both keyboard and direct terminal input after reattachment. | Proven on native Windows (`electron-headless`, 24.0 s) | -| Internal review-until-clean and final gates | The original three review rounds and both additional PR review loops are clean. The latest loop fixed four oracle/performance/gate gaps, then completed a clean full-diff re-review. Typecheck, lint, format, reliability manifest, max-lines ratchet, Electron build, native harness, and focused suites pass apart from the documented untouched baselines. | Proven | -| Public GitHub and real daemon safety | No public GitHub mutation occurred. Fixture guards reject non-temporary roots and known Orca user-data paths; no installed pipe/token/session was discovered or contacted. | Proven | - -Live Linux SSH validation remains an explicit gap: the required throwaway -Docker target is unavailable on this runner (`docker` is not installed and no -Docker Desktop process or standard executable path exists), and `wsl.exe` -reports that WSL is not installed. A real remote or localhost was deliberately -not substituted. The Orca worktree comment was updated through the scoped -`worktree set --comment` CLI with the investigation, RED reproduction, -root-cause, implementation, native-validation, and clean-review milestones. -No terminal or daemon command was issued through the CLI, and no public GitHub -comment was made. diff --git a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md b/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md deleted file mode 100644 index b3453c0c1d2..00000000000 --- a/docs/reference/plans/2026-07-27-ssh-reconnect-fanout.md +++ /dev/null @@ -1,1009 +0,0 @@ -# Direct SSH reconnect fan-out - -Status: implemented and validated; ready for maintainer merge - -This design was reviewed in two full rounds, reconciled with current main, and implemented across main, preload, renderer, shared contracts, reliability gates, and Docker SSH fixtures. Review findings are absorbed into the invariants and implementation below. - -Validated against `origin/main` at `21dee21a6d9d398bb332ddf0f85fbb4d5de7cd1b`. Current main includes remote-runtime resume/online recovery (#8255), worktree-owned multi-host routing (#10986), negotiated paired close intent (#10129), fail-closed runtime SSH setup (#10799), terminal-view parking (#11016), host-correct SSH folder adoption (#10818), hydration-loop ID indexes (#10891), runtime output chunking (#10915), consolidated changed-code quality gates (#11117), orchestration migration safety (#11107), and CLI-compatible remote timeout parsing (#11206). None replaces the direct desktop SSH reconnect path, but each constrains its ownership and lifecycle integration below. - -Scope: direct SSH reconnect recovery across main, preload, and renderer - -## Summary - -One direct SSH reconnect currently starts two sequential target-preparation waves, or three when a nonempty remote-workspace snapshot is applied. Each wave refreshes every target repo and then lineage. Because the next preparation starts after the previous one settles, the existing in-flight repo coalescer does not collapse these sequential scans. Simultaneous targets multiply the work, while disconnect handling separately calls the single-tab `clearTabPtyId` action once per live tab. - -Replace this with host-qualified, epoch-fenced provider reads and a renderer coordinator with two distinct modes: - -- reconnect finalization retries the exact target's terminal panes immediately, before optional discovery; -- preparation-only refreshes the exact target's catalog, worktrees, and lineage without remounting panes. - -Main owns one provider-incarnation authority per direct SSH target. It is a strict composition of the existing `connectionGeneration` and a new opaque `providerEpoch`: one helper rotates both atomically on the same transition set, and provider work carries and compares the pair. Existing file-mutation fencing continues to carry `connectionGeneration`, but it now advances on every provider-invalidating transition too; reconnect recovery cannot advance one clock without revoking the other. Every authoritative request carries the exact execution host and captured authority through preload to main; main selects the provider by that complete identity and revalidates the authority before any durable side effect. Renderer preparation deduplicates only overlapping work with identical concrete inputs. A completed preparation is not cached for the lifetime of an authority. - -Direct SSH provider calls use a dedicated fair limiter with five local slots, explicit deadlines, and cancellation. Cancel acknowledgement means local waiter settlement; the existing fire-and-forget `rpc.cancel` does not acknowledge relay-handler completion. A separate cancel-debt allowance bounds replacement admission while relay work may be finishing. The five-slot bound applies only to locally unsettled detected-worktree provider calls submitted by this coordinator; aggregate telemetry reports the late-work allowance and other same-relay traffic separately. Runtime discovery, sidebar refreshes, filesystem events, catalog reads, and lineage reads retain their own concurrency behavior and are measured separately. - -This remains separate from the sidebar fix in `9d3ae3adc7`, which does not own `ssh:state-changed`, remote-workspace preparation, or terminal binding cleanup. - -## Goals - -- Recover terminal panes without waiting for Git discovery on the same or another target. -- Keep coordinator-owned direct SSH provider work bounded and fair across targets. -- Make provider selection correct when repo IDs collide across local, direct SSH, and remote-runtime hosts. -- Reject obsolete provider results before any main or renderer authoritative mutation. -- Preserve final-state convergence for later same-connection remote snapshots and wake refreshes. -- Keep disconnect and retry scope symmetric across Git worktrees and folder workspaces. -- Preserve relay reattach identifiers and terminal/session recovery semantics. -- Make timeout, cancellation, non-authoritative data, and operational failure separately observable without exposing identifiers. - -## Non-goals - -- Changing SSH credentials, backoff policy, or user-facing reconnect controls. -- Moving runtime-owned work into the direct SSH coordinator. -- Changing the component-scoped sidebar refresh queue added by `9d3ae3adc7`; the shared detected-worktree provider coalescer does gain lease accounting. -- Expanding remote-workspace serialization to folder workspaces. -- Changing Git commands, worktree parsing, or Git capability detection. -- Changing the remote-runtime wire protocol. -- Establishing a renderer-wide or application-wide provider-call ceiling. - -## Current flow and root cause - -### Connected path - -`applySshConnectionStateChange` currently: - -1. filters `store.repos` by raw `connectionId`; -2. calls `Promise.all(remoteRepos.map(fetchWorktrees))`; -3. calls `fetchWorktreeLineage`; -4. scans target worktrees and bumps terminal generations one worktree at a time; and -5. calls `syncRemoteWorkspaceAfterConnect`. - -`syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, repeating repo and lineage refresh. A nonempty snapshot then reaches `applyRemoteWorkspaceSnapshot`, which prepares a third time. - -For a target with `R` repos, the connected path therefore performs `2R` detected-worktree scans and two lineage reads without a snapshot, or `3R` scans and three lineage reads with one. The existing detected-worktree single-flight joins only requests whose provider reads overlap. These preparation waves are sequential in the current call chain, so it does not collapse their scan count. Other overlapping callers can still join a scan; telemetry must measure observed calls rather than infer a global `kR` multiplier. - -### Disconnect path - -Terminal failure states walk repo-derived worktrees and call `clearTabPtyId(tab.id)` for each tab whose `ptyId` is present. The general single-tab action scans workspace buckets, clones global terminal maps, publishes store changes, bumps worktree activity, and can persist metadata on every call. - -For `T` live tabs, `W` workspace buckets, and terminal maps of size `M`, synchronous work is approximately `O(T × (W + M))`, plus repeated Zustand publications and session-persistence debounce resets. Connection loss is not user activity. - -### Boundary and ownership defects - -- Renderer `fetchWorktrees(repoId, { executionHostId })` uses the host to choose and stamp renderer ownership, but the local preload/main request currently carries only `repoId`. -- Main then calls first-match `store.getRepo(repoId)` and cannot enforce the renderer's intended host. The proven producer is renderer-catalog aliasing across hosts, not duplicate UUID rows created by main; same-ID main rows remain a defensive ambiguity case. -- Main can prune lineage and backfill metadata before renderer receives a result, so a renderer-only stale-result fence is insufficient. -- Current main-owned `connectionGeneration` and the renderer-local state-change counter have different sources and advance rules. Only the main-owned value is authoritative. -- Relay replacement can change the provider incarnation without advancing the existing main-owned generation. The correction is to rotate the generation and provider epoch together, not to use the renderer counter. -- Raw repo-ID filtering can select another execution host. Folder workspace keys are omitted entirely. -- Direct SSH catalog and lineage preparation follows focused-runtime ownership in some paths. -- Direct SSH lineage preparation currently calls bare `fetchWorktreeLineage()`, whose ownership can follow focused-runtime settings rather than the SSH target. - -### Deterministic current-main baseline - -The falsifiable current-main invariants are: - -1. one connected direct SSH event must issue at most one detected-worktree scan per exact repo input and one host-qualified lineage read; -2. an authoritative detected-worktree or lineage result must identify one exact execution host and, for direct SSH, the complete provider authority; -3. explicit worktree, repo-derived, runtime-owner, folder, and PTY provenance must not contradict one another; and -4. a first timeout classified as retryable must not release lineage, sync, or token creation before its retry reaches a terminal outcome. - -Current main violates the first two at the smallest deterministic seams: - -- `applySshConnectionStateChange` runs `fetchWorktrees` plus lineage and then calls `syncRemoteWorkspaceAfterConnect`; -- `syncRemoteWorkspaceAfterConnect` calls `prepareRemoteWorkspaceTarget`, and a nonempty snapshot calls `applyRemoteWorkspaceSnapshot`, which calls it again; -- the shared renderer coalescer joins only overlapping promises and deletes the entry when they settle, so these awaited sequential waves remain `2R` scans and two lineage reads without a snapshot or `3R` and three with one; -- renderer refresh selection accepts `executionHostId`, while preload `worktrees.listDetected` and main `worktrees:listDetected` accept only `{ repoId }`; main then uses first-match `store.getRepo(repoId)`. - -Baseline commands run from the rebased worktree: - -```bash -pnpm install --frozen-lockfile -rg -n "prepareRemoteWorkspaceTarget|applyRemoteWorkspaceSnapshot|syncRemoteWorkspaceAfterConnect|store.fetchWorktrees|fetchWorktreeLineage" src/renderer/src/hooks/useIpcEvents.ts -rg -n "listDetected: \\(args: \\{ repoId: string \\}\\)|store.getRepo\\(args.repoId\\)" src/preload/api-types.ts src/main/ipc/worktrees.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktrees.test.ts -t "coalesces concurrent duplicate refreshes for the same repo and host|keeps same-repo refreshes separate for different execution hosts|fetches the requested host when duplicate repo ids exist" -pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktrees.test.ts -t "coalesces concurrent authoritative detected worktree scans" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts -t "clears stale remote PTYs when an SSH connection fully disconnects|waits for the remote workspace client id before dropping self notifications" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/ssh-reconnect-pane-retry.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-channel-multiplexer.test.ts src/renderer/src/runtime/use-remote-runtime-recovery-triggers.test.ts -t "surfaces relay channel loss while the SSH connection remains alive|does not broadcast a premature connected when relay deploy fails|times out after 30s with no response|advances both recovery schedulers" -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts src/renderer/src/lib/manual-terminal-worktree-parking.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/agent-background-session-launch-host.test.ts src/renderer/src/lib/launch-agent-background-session-remote.test.ts src/renderer/src/hooks/useAutomationDispatchEvents.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/worktree-by-id-index.test.ts src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts -``` - -Results initially on `79ec57d04`, then rerun after rebasing through `974447175`: 3/3, 1/1, 2/2, 4/4, and 3/3 selected SSH seam tests passed respectively; the three newly relevant parking files passed 39/39. On `1fd0f731f`, the four folder-automation/adoption files passed 516/516. On `694363805`, the hydration-index and output-frame equivalence files passed 25/25. Source-contract inspection confirmed the `2R`/`3R` call graph and the dropped host field; later main changes did not alter those seams. This is the historical baseline evidence used to define the candidate oracles. Candidate runtime and Docker SSH evidence is recorded separately below. - -### Candidate implementation and validation - -The candidate implements the composed authority pair, host-qualified catalog/worktree/lineage reads, separate waiter and provider identities, five-slot fair scheduling with a seven-start provider budget, the first-timeout retry barrier, exact-target terminal recovery, fenced remote-workspace hydration, and privacy-safe aggregate telemetry. Coordinator routing defaults on and can be disabled per build with `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or per renderer session with `orca.directSshReconnectCoordinator.enabled=false`; the fallback retains host/authority fencing, atomic terminal recovery, and bounded preparation. - -Deterministic validation on the final rebased implementation passed all 42 changed unit suites (1,960 tests), including the 11-file direct-SSH terminal gate (637 tests), the 11-file renderer provider/transport gate (763 tests), and the 12-file main/preload/runtime/shared authority gate (513 tests), plus full typecheck, changed-code quality, reliability-gate and max-lines checks, `git diff --check`, and an Electron E2E-mode build. The authority gate proves retained SSH connection and detected-port payload admission is wired into native preload and runtime-client/environment production routes. An earlier full `pnpm test` exercise ran 3,707 files and 39,083 tests; only two assertions in unchanged `agent-exec-handler.test.ts` failed because the managed terminal already injects `GIT_CONFIG_*` prompt settings. The file passed 10/10 with those inherited settings removed. Final exact-head CI evidence is recorded in the PR description. - -Real transport validation used a macOS Electron headless client against an ephemeral Linux Docker SSH/relay target: - -```bash -ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ - tests/e2e/ssh-docker-relay-perf.spec.ts \ - --config tests/playwright.config.ts \ - --project electron-headless \ - --workers=1 - -ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test \ - tests/e2e/ssh-cold-activation-restore.spec.ts \ - --config tests/playwright.config.ts \ - --project electron-headless \ - --workers=1 -``` - -The four-test relay suite and the cold-restore journey passed. The relay suite covered streaming, a background ACK-stalled PTY, file/Git pressure, and live terminal input/output before and after SSH disconnect/reconnect; the reconnect case independently read the post-reconnect proof file inside the Linux container. The cold-restore journey proved all six restored SSH terminals remounted and accepted remote input after renderer reload. Repo registration waits on exact renderer catalog ownership and full authority, requires an authoritative host-qualified worktree response, and uses no timing sleep. - -Remaining live gaps are headed paired-Orca-server and headless `orca serve` non-interference, WSL, physical Windows and Linux desktop clients, and a multi-target live fan-out/large-terminal-map benchmark. Docker SSH proves the direct SSH provider/relay path, not paired-runtime parity. - -Current-main reconciliation: - -- #8255 advances paired remote-runtime control and pane backoffs on resume/online. Direct SSH registers its own wake input and must not call or absorb that scheduler; one OS event may wake both ownership domains, but each exact pane finalizes at most once. -- #10986 correctly routes an exact worktree by its own host in multi-host projects. Direct reconnect reuses that worktree-specific precedence only after rejecting any contradictory provenance; it never falls back to project-wide repo ambiguity or focused-runtime ownership. -- #10129 makes capable paired-runtime reasonless close fail closed. Direct SSH cleanup clears transient bindings only: it never emits `session.tabs.close`, retires a tab, or kills a provider PTY. -- #10799 confirms runtime project setup must refuse `ssh:` rather than act locally. The desktop direct-SSH handler likewise rejects `runtime:` hosts, and runtime-owned SSH rows remain under runtime authority. -- #11016 parks terminal views without clearing their PTYs. Direct SSH binding cleanup does not invoke parking, close, or layout mutation; the mounted view observes the atomic PTY-state patch independently. -- #10818 routes folder automation through `getKnownWorktreeById` and ambiguity-aware `getFolderWorkspaceConnectionId`, and publishes agent tabs only after binding the spawned PTY. Direct reconnect uses the same effective folder connection as provenance, rejects mixed ownership, preserves agent state, and never treats an adopted PTY as live under a newer authority without matching binding evidence. -- #10891 preserves legacy first-wins semantics while indexing ID-only hydration lookups. Direct reconnect must not treat `buildWorktreeByIdIndex` as ownership proof: target hydration and reattach resolve a host-qualified worktree first and fail closed on duplicate or contradictory ownership. -- #10915 changes runtime output-frame chunking without changing PTY binding, authority, close intent, or hydration ownership. Direct reconnect does not reset output sequence state or reinterpret runtime frames. - -## Invariants - -### Authoritative provider authority - -1. Main is the sole issuer of `SshProviderEpoch` and `connectionGeneration`. Renderer never increments, orders, parses, or synthesizes either value. -2. Direct SSH state is normalized to `providerEpoch: SshProviderEpoch | null` plus `connectionGeneration?: number`. Main-originated direct SSH state always carries a valid pair. `null` or a missing generation means unknown authority, not zero and not a renderer fallback. -3. A single `rotateSshProviderAuthority(targetId)` helper advances both values atomically before every provider-invalidating transition: new connect ownership, transport replacement or loss, relay/multiplexer replacement or loss, provider disposal, target readoption/reassignment, and permanent target removal. No producer may rotate or publish only one component. -4. `connectionGeneration` remains the existing SSH mutation expectation. Expanding its rotation set closes relay-only mutation races. Provider/reconnect operations compare the full `(providerEpoch, connectionGeneration)` pair; this is the formal composition rule, not two independent clocks. -5. Main registers the provider serving the new pair before broadcasting `connected`. A broadcast-first transition is invalid because it can start a preparation against an unregistered provider with no guaranteed self-heal event. -6. Renderer partial state writers are patches. They preserve both main authority fields and cannot author a `connected` state with a new value. -7. Every boundary that decides equality, copies state, admits retained state, preloads state, reconciles state, or republishes state must preserve both fields. This inventory includes `sshConnectionStatesEqual`, `admitSshConnectionState` and its byte/range checks, public-state projection, `ssh:getState`, `ssh:state-changed`, startup reconnect, runtime-client retained payloads, renderer/runtime SSH state stores, web-file mutation reads, and all fixtures/builders. Allowlisted clones must name both fields; equality treats either field changing as significant. -8. A recovery operation captures one exact `DirectSshAuthority = (targetId, providerEpoch, connectionGeneration)`. Equality is the only permitted authority operation. -9. If a connected event lacks either component, the renderer performs one bounded `ssh:getState` reconciliation with a per-target arrival watermark. The reply may fill authority only if no newer push event arrived and the stored event/status still matches the initiating event; it cannot transition status or resurrect an older `connected` state. If authority remains unknown, authoritative preparation, retry, sync, and snapshot mutation fail closed with `authority-unknown`; disconnect cleanup remains allowed. -10. After every await and inside every authoritative store updater, current state must still name the same connected target and exact authority pair. -11. Supersession is determined by coordinator arrival order and exact equality, never numeric ordering. -12. On any authority change, before new preparation admission, cancel the target's queued work, locally settle obsolete waiter leases, send exactly one cancellation for every affected in-flight provider request ID, mark all late results stale, and retain main-side post-await fences. Terminal finalization for the new authority does not wait for old relay work. -13. Exhausting one target's per-session generation counter rolls the process generation scope and revokes every direct SSH target, not only the target that exhausted its counter. Main invalidates every cached authority and aborts every registered provider request from the old scope before any target can publish or admit work under the new scope. - -### Host-qualified ownership - -1. Direct reconnect work owns only `toSshExecutionHostId(targetId)`. -2. Every coordinator detected-worktree provider invocation carries `(repoId, executionHostId, expectedAuthority, providerRequestId)`. Each renderer consumer separately owns a `waiterLeaseId`; a lease ID never crosses IPC or names provider work. -3. Main resolves a repo by the complete `(repo.id, executionHostId)` identity only after validating all present repo provenance. `executionHostId` and legacy `connectionId` must agree when both are present; a catalog row for which either source names the requested host while the other names another SSH, local, or runtime host makes the host catalog non-authoritative. Zero, contradictory, or multiple matches fail closed. Main never uses explicit-field precedence to hide a contradiction and never falls back to first-match `getRepo(repoId)` for a host-qualified request. -4. A local host request can select only a local repo. A direct SSH host request can select only the matching target/provider. A runtime host is rejected by the desktop handler and must use the existing runtime RPC route. -5. A successful response uses a local or direct-SSH discriminant. The direct-SSH variant cannot be constructed without the resolved execution host and full authority pair. Renderer validates the wire discriminant and rejects a mismatch before any use. -6. Runtime-owned or runtime-transported worktrees remain under the runtime environment scheduler, including SSH execution hosts whose `runtimeOwnerEnvironmentId` names a HUB. -7. Raw repo IDs, paths, UI focus, and unqualified legacy metadata are not ownership evidence. Pre-catalog scope resolution uses explicit worktree/repo provenance and `getExplicitRuntimeEnvironmentIdForWorktree`; it must not use the focused-runtime fallbacks in `getExecutionHostIdForWorktree` or `getRuntimeEnvironmentIdForWorktree`. `runtime:unresolved-owner` and focus-only results are ambiguous, not another host's. Unknown ownership is diagnostic and retryable, but never authoritatively replaced or deleted. -8. All present provenance must agree. Explicit worktree ownership takes precedence only after agreement is proven; repo-derived ownership is a fallback only when explicit worktree ownership is absent. A worktree stamped `ssh:B` with repo-derived `ssh:A`, or any direct-SSH row with an explicit runtime owner, is `contradictory-owner` and is preserved without refresh, merge, retry, or pruning. - -### Merge fencing - -1. Provider results are immutable until all host and authority checks pass. -2. Main revalidates the request host, provider instance, and exact authority after the provider await and before: - - remembering worktree roots; - - pruning persisted lineage; - - stamping or backfilling worktree metadata; or - - returning an authoritative result. -3. Renderer revalidates immediately after the preload/runtime await and before any use of the result, including: - - `routeListingBranchSwitchesThroughGitIdentity`; - - hosted-review sanitation; - - `updateWorktreeGitIdentity`; - - `buildWorktreePurgeState`; - - `worktreesByRepo` or `detectedWorktreesByRepo` merges; and - - best-effort lineage refresh. -4. Host-scoped catalog and lineage snapshots are revalidated before their sole renderer merge. -5. A remote-workspace token and snapshot revision are revalidated after hydration and immediately before session merge/publish. The merge also preserves any terminal-recovery revision newer than the snapshot operation. -6. Stale, superseded, timed-out, and canceled results perform zero authoritative mutations and are not logged as operational errors. - -### Terminal state - -The disconnect action preserves the current call-site predicate: a tab is affected only when `tab.ptyId != null`. A tab without a `ptyId` remains byte-identical even if it has `pendingActivationSpawn` or an inconsistent auxiliary PTY index. - -For each affected tab, the atomic action must: - -- set `tab.ptyId` to `null`; -- empty its `ptyIdsByTabId` entry; -- consume `pendingActivationSpawn`; -- remove pending Codex restart and restart-notice entries for the cleared live PTY; -- preserve `lastKnownRelayPtyIdByTabId` for relay-grace reattach and the `#9911` orphan-safety invariant; and -- leave layouts, deferred SSH sessions, pending reconnect IDs, suppression/shutdown guards, tab IDs, titles, generations, and agent state unchanged. - -The action must not bump activity, sort worktrees, or persist worktree metadata. A repeat after all qualified bindings are clear returns the original store state. -It also must not emit `session.tabs.close`, `session.tabs.closeLifecycle`, provider shutdown, or process signals. Binding loss is not close intent, tab retirement, or proof that a PTY died. - -Reconnect finalization must: - -- use `shouldRetryPaneSpawnOnSshReconnect`; -- include exact direct SSH Git-worktree and folder-workspace keys; -- run synchronously before catalog or provider awaits; -- treat a non-null `ptyId` as live only when transient binding provenance names the current authority and the current tab-wide spawn/reattach attempt has established live authority; -- clear stale binding evidence from a missed disconnect or prior authority before testing retry eligibility; -- keep at most one tab-wide retry attempt in flight per tab and authority; -- join renderer pending-spawn promises only for the same retry attempt; authority or tab-generation advance starts independently, and a late obsolete fresh PTY is rejected and retired; -- accept a spawn or reattach acknowledgement only when its attempt, authority, tab generation, target-qualified PTY, and committed PTY index all match; the first split success establishes the tab fallback and a live continuation lease, and later or post-success-mounted siblings capture and commit through that exact lease without replacing the fallback; -- admit a reattach identity before publishing renderer PTY handlers; revalidate the captured lease after every asynchronous SSH preparation wait and, after the synchronous already-exited delivery case, before error or launch-metadata publication, deferred-state mutation, binding cleanup, or replacement spawn; drop stale owned handlers without killing the durable PTY a current lease may adopt; -- settle retirement of a newly created session-expired fallback when admission rejects it, including after transport destruction, and surface shutdown refusal as unknown, without killing a rejected reattach or cold restore that another current lease may adopt; -- preserve the continuation lease if its primary PTY exits while another split leaf from the same attempt is still activating, then promote the late sibling when it commits; -- on an attempt-one sibling failure or timeout, revoke that attempt and rotate the whole tab once; after attempt two is exhausted, retain its continuation lease for siblings already settling while forbidding attempt three; -- when a split pane detaches to a new tab, project the same exact live or pending authority and retry history to both resulting tabs, including a bound-plus-unbound split or an all-null continuation gap before any leaf binds; the detached null-PTY tab remains activation-pending, and a rejected acknowledgement or detach returns the original relevant maps unchanged; -- permit a tab hydrated, newly discovered, or left unbound after a failed spawn to receive a bounded same-authority corrective bump; -- update all affected workspace buckets in one Zustand publication; and -- leave preparation-only requests, nonqualifying tabs, and every other host unchanged. - -The coordinator keeps separate authority-scoped pending-attempt state and successful-binding state, not a set of bump attempts and not a cached preparation outcome. The live binding carries the exact attempt ID as a continuation lease for every split leaf in that tab generation. A healthy live binding, including a bounded empty-primary activation gap, suppresses correction; an unresolved tab is reconsidered on wake, snapshot completion, and preparation completion with at most one tab-wide attempt in flight. One authority chain preserves its complete attempt history and has a hard limit of two automatic attempts. A timeout taking longer than the former rolling 30-second window cannot age out the first attempt and start a third automatic attempt; later wake, snapshot, and preparation triggers remain exhausted until authority replacement rotates pending, binding, and history state. - -## Design - -### 1. Composed provider authority and host-qualified IPC - -Add `SshProviderEpoch` to the shared SSH types and compose it with the existing main-owned connection generation in `src/main/ssh/ssh-provider-authority.ts`. This module owns atomic rotation and delegates generation storage/assertion to `ssh-connection-generation.ts`; it does not introduce an independently advancing clock. - -The direct SSH state boundary becomes: - -```ts -type SshProviderEpoch = string & { readonly __sshProviderEpoch: unique symbol } -type ProviderRequestId = string & { readonly __providerRequestId: unique symbol } -type WaiterLeaseId = string & { readonly __waiterLeaseId: unique symbol } -type SshExecutionHostId = Extract - -type DirectSshStateAuthority = { - providerEpoch: SshProviderEpoch | null - connectionGeneration?: number -} - -type DirectSshAuthority = { - targetId: string - providerEpoch: SshProviderEpoch - connectionGeneration: number -} -``` - -The epoch wire value is a bounded opaque string. Branding is compile-time only. Main state broadcasts and `ssh:getState` include the pair; renderer state stores it without interpretation. `rotateSshProviderAuthority` is the only transition writer and `assertSshMutationExpectation` observes the generation advanced by that same call. - -Extend preload/main detected-worktree APIs: - -```ts -type LocalDetectedWorktreeRequest = { - providerRequestId: ProviderRequestId - repoId: string - executionHostId: typeof LOCAL_EXECUTION_HOST_ID -} - -type DirectSshDetectedWorktreeRequest = { - providerRequestId: ProviderRequestId - repoId: string - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority -} - -type ListDetectedWorktreesArgs = LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest - -type AuthoritativeHost = - | { - kind: 'local' - executionHostId: typeof LOCAL_EXECUTION_HOST_ID - } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - -type HostQualifiedDetectedWorktreeResult = - | { - status: 'complete' | 'non-authoritative' - providerRequestId: ProviderRequestId - repoId: string - authority: AuthoritativeHost - result: DetectedWorktreeListResult - } - | { - providerRequestId: ProviderRequestId - executionHostId: ExecutionHostId - status: - | 'canceled' - | 'timed-out' - | 'stale' - | 'ambiguous-owner' - | 'authority-unknown' - | 'rejected' - } -``` - -`DirectSshDetectedWorktreeRequest` requires the full pair as one `expectedAuthority`; local requests cannot carry it. Construction and runtime admission also require `executionHostId === toSshExecutionHostId(expectedAuthority.targetId)`. Every data-bearing direct-SSH response, including `non-authoritative` metadata fallback, is therefore impossible to construct without both fields. Runtime validation rejects decoded SSH data payloads missing either component even if an untyped or older boundary fabricates one. Main owns the 30-second provider deadline; no renderer-supplied timeout can extend it. The desktop handler rejects runtime hosts. Keep an explicitly unqualified legacy overload only for existing callers during migration; it fails closed when more than one host owns the repo ID and is removed after all callers pass a host. - -Main selects the repo and provider before starting work, captures the provider object and full authority, and checks all three again after `provider.listWorktrees`. A host-qualified SSH response cannot be restamped by renderer as another host. - -Use host-qualified lineage ownership as well: - -```ts -type ListDesktopLineageForHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -type HostLineageSnapshot = - | { - authoritative: true - authority: AuthoritativeHost - worktreeLineageById: Record - workspaceLineageByChildKey: Record - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' - } -``` - -Main filters the snapshot to the requested host. Renderer replaces only an authoritative discriminated host scope; malformed SSH authority is rejected before merge. Direct SSH preparation replaces the current bare `fetchWorktreeLineage()` call with this host-qualified API; it does not substitute `{ forceLocalOwner: true }`. - -Main destructive pruning must use qualified repo/worktree ownership. An existing row `meta.hostId` must agree with the resolved repo host in every case. Only an absent legacy host may be inferred when exactly one stored repo owns the repo ID, preserving today's path-reuse cleanup. When multiple hosts can own the ID, pruning requires row `meta.hostId`; absence or conflict makes the result non-authoritative and preserves the row. Authoritative scans backfill absent `meta.hostId` so legacy rows self-heal. Never use repo-ID-prefix pruning across hosts. - -The shared renderer store and web preload retain their existing non-direct-SSH callers. Preserve the legacy/runtime overload and its argument/echo shape in `web-preload-api.ts`, but do not broaden the web client into direct-SSH coordination: paired web clients cannot subscribe to desktop `ssh:state-changed`, so the proposed total direct-SSH web break is not reachable. Shared API type changes still receive compatibility tests so a runtime-routed web read is not rejected merely because the wrapper dropped its requested host. - -### 2. Exact direct SSH target scope - -Add `src/renderer/src/lib/direct-ssh-target-scope.ts`: - -```ts -type DirectSshGitRepoRef = { - repoId: string - executionHostId: SshExecutionHostId -} - -type DirectSshTargetScope = { - catalogRevision: number - gitRepos: DirectSshGitRepoRef[] - gitWorktreeIds: Set - terminalWorkspaceKeys: Set - lineageWorkspaceKeys: Set - ambiguousOwnerCount: number - contradictoryOwnerCount: number -} -``` - -Resolution rules: - -- Build the expected host with `toSshExecutionHostId(targetId)`. -- Resolve repos by `(repo.id, executionHostId)`. -- Collect explicit worktree host, exact repo-derived host, projected runtime owner, and any restored host evidence before selecting a row. -- If two present sources disagree, classify the row as `contradictory-owner`; preserve it and exclude it from refresh, merge, terminal retry, snapshot projection, and pruning. -- When explicit worktree host is present and no source contradicts it, require it to equal the expected host. Use exact repo-derived ownership only when the explicit worktree host is absent. -- Require `getExplicitRuntimeEnvironmentIdForWorktree` to be `null`; an explicit runtime owner contradicts direct SSH even when another source names the expected SSH host. -- Do not use focused-runtime fallback ownership during pre-catalog reconnect. Focus-only local/runtime results and `runtime:unresolved-owner` are ambiguous. -- Do not use `buildWorktreeByIdIndex`, raw `getKnownWorktreeById`, or another first-wins ID-only lookup as authoritative direct-SSH ownership. Hydration and reattach use an exact host-qualified row or fail closed. -- Accept a folder workspace only when its effective connection is exactly `targetId`, its execution host is expected, runtime owner is `null`, and all candidate repo/group/workspace provenance agrees. -- Treat mixed/conflicting folder provenance as contradictory; duplicate same-host owners and unresolved legacy rows are ambiguous/unowned. -- A parsed live app-SSH PTY can recover a stale-catalog terminal only when no explicit other-host or runtime ownership contradicts it. - -Git refresh uses `gitRepos`; terminal clear/retry uses raw Git IDs and folder keys from `terminalWorkspaceKeys`; unified lineage uses `worktree:` and folder keys from `lineageWorkspaceKeys`; snapshot projection uses `gitWorktreeIds`. Folder workspaces never enter the path-based remote-workspace schema. - -When exact repo rows are missing, use a new host-scoped desktop catalog read rather than focused `fetchRepos()` or an all-desktop refresh: - -```ts -listReposForExecutionHost({ - executionHostId, - expectedAuthority -}) -``` - -Main validates explicit and legacy ownership before producing the host snapshot. A row with contradictory `executionHostId` and `connectionId` cannot be filtered into one host by precedence or silently omitted from the other; a contradiction touching the requested host returns a non-authoritative catalog with no rows. - -The renderer merges the immutable response into only that host scope after a full-authority fence. This action owns a per-host catalog revision and in-flight entry; it does not share `reposFetchGeneration` with focused-runtime or all-host fetches. Thus a concurrent runtime-focused catalog refresh cannot silently supersede direct SSH hydration. -Its authoritative response uses the same `AuthoritativeHost` discriminant as detected worktrees and lineage, so direct-SSH catalog data also requires the complete pair. - -### 3. Bounded and cancelable direct SSH provider scheduler - -Add `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts`. It is owned by the `useIpcEvents` effect and used only for direct SSH coordinator scans. - -Required behavior: - -- at most `DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY` (five) locally unsettled coordinator-owned detected-worktree requests in flight; -- owner-aware round-robin selection by target, with repos submitted incrementally rather than flattening one target into a global FIFO; -- no fixed collection window for an idle singleton request; -- an input key of `(repoId, executionHostId, providerEpoch, connectionGeneration, catalogRevision, authoritative requirement)`; -- join only a currently running logical repo task with the exact same key; -- retain the logical key across a first `retrying` timeout and delete it only on a terminal outcome; -- explicit `complete`, `non-authoritative`, `timed-out`, `cancel-budget-exhausted`, `canceled`, `stale`, and `rejected` outcomes; and -- no console error or degraded count for expected cancellation/supersession. - -Each provider invocation has the existing 30-second main-owned deadline. Main creates an `AbortController`, passes its signal to `SshGitProvider.listWorktrees`, and therefore reaches the multiplexer `rpc.cancel` path on timeout. A transient first timeout changes the repo task to `retrying` and requeues it once at the tail of that target's round-robin lane if the full authority remains current. That repo task promise does not settle, and target lineage/token creation does not start, until the retry completes, reaches its second timeout, is invalidated, or retry admission terminates as `cancel-budget-exhausted`. Add cancellation IPC keyed only by `providerRequestId`; authority advance, target invalidation, last-waiter release, and effect teardown abort matching main requests. Queued requests have no provider request ID and cancel without IPC. - -The first timed-out provider invocation and its leases settle before retry admission. The logical repo task remains pending and acquires a fresh provider request ID plus fresh waiter leases for the retry; preparation waiters never reuse an already-canceled provider identity. - -Cancellation uses waiter leases: - -- `waiterLeaseId` and `providerRequestId` are different opaque types generated independently. A provider request ID names one underlying preload/main/provider invocation; every consumer of that shared invocation receives its own renderer-only waiter lease ID. -- The shared detected-worktree coalescer, not the direct coordinator alone, owns the lease registry so sidebar/filesystem consumers also keep a joined provider invocation alive. -- Canceling or superseding one lease settles only that consumer. It does not call cancellation IPC while any other lease remains. -- Releasing the last lease sends exactly one cancellation IPC carrying the provider request ID and captured host/authority. Main never receives, stores, or accepts waiter lease IDs. -- Main aborts the provider request when that cancellation identity matches or when provider authority is invalidated. It does not reconstruct waiter ownership. -- Local cancellation returns after waiter settlement and, for the last lease, main provider-promise settlement. `ssh-channel-multiplexer` rejects its local provider promise when it sends fire-and-forget `rpc.cancel`; no relay response is awaited. -- The scheduler releases the local slot when the underlying provider promise settles locally. The original relay handler may observe abort later, so this metric is not a hard relay-process concurrency claim. -- Track every locally canceled underlying call as conservative cancel debt on its provider instance. Admission requires `locallyUnsettled + cancelDebt <= DIRECT_SSH_PROVIDER_START_BUDGET` (seven), so five canceled calls permit at most two replacements and repeated cancel/retry cannot create unbounded client-originated work. Debt is not cleared by elapsed time or local promise settlement because neither proves relay completion; it clears only when the owning provider/multiplexer is disposed or replaced. A logical task denied admission by this budget settles terminally as `cancel-budget-exhausted`; it never waits for provider replacement and never leaves the preparation barrier pending indefinitely. A hard bound on handlers surviving disposal is impossible without a relay acknowledgement, so telemetry states this as a seven-start per-provider budget rather than a total remote-process guarantee. - -```ts -type DetectedWorktreeRefreshLease = { - waiterLeaseId: WaiterLeaseId - providerRequestId: ProviderRequestId - result: Promise - release(reason: 'superseded' | 'invalidated' | 'stopped'): void -} -``` - -Each lease has its own settlement promise. Normal provider settlement resolves all remaining leases and removes the provider entry automatically; early `release` is idempotent and settles only that lease as canceled. - -Every other preparation await is bounded too: host-scoped catalog and lineage IPC use five-second deadlines, workspace hydration retains its ten-second deadline, and existing remote-workspace RPC deadlines remain in force. Catalog and lineage waiters accept coordinator cancellation; because their main work does not launch a provider process, a late reply is discarded by the renderer fence rather than holding a scheduler slot. - -The existing runtime project scheduler keeps its own five-worker pool, 250 ms debounce, and 5-second minimum interval. Sidebar and filesystem-event refreshes remain unchanged. Cross-subsystem isolation is intentional; the coordinator's bound is not presented as a renderer-wide bound. - -Keep the public `detectedWorktreeRefreshKey` shape unchanged. Its in-flight entry records a provider invocation identity and a lease map; direct-SSH authoritative work may join only when host and full authority also match. An incompatible entry under the same public key gets a separate underlying invocation rather than an unsafe join. The coordinator's authority/revision key wraps this coalescer without fragmenting compatible sidebar or filesystem-event sharing. The shared coalescer owns provider request IDs and waiter leases; the coordinator owns authority/revision fencing, retry state, and scoped metrics. - -### 4. Per-target reconnect coordinator - -Add `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts`, instantiated once in `useIpcEvents`: - -```ts -type DirectSshAuthority = { - targetId: string - providerEpoch: SshProviderEpoch - connectionGeneration: number -} - -type PreparationInput = DirectSshAuthority & { - catalogRevision: number - repoRefs: DirectSshGitRepoRef[] - authorityRequirement: 'required' | 'allow-metadata-fallback' - snapshotRevision?: number - reason: 'reconnect' | 'initial-hydration' | 'workspace-snapshot' | 'wake-refresh' -} - -type DirectSshReconnectCoordinator = { - requestReconnect(authority: DirectSshAuthority): Promise - prepareOnly(input: PreparationInput): Promise - finalizeHydratedTerminals(authority: DirectSshAuthority): number - correctUnboundTerminals(authority: DirectSshAuthority, reason: CorrectionReason): number - replaceAuthority(authority: DirectSshAuthority): void - invalidate(targetId: string): void - stop(): void -} -``` - -There is no global reconnect wave and no authority-long prepared-outcome cache. Transient per-tab pending/live-binding state is separate and exists only to settle or re-arm terminal recovery. - -`replaceAuthority` compares the complete authority tuple. An exact-equal replacement is a no-op: it does not settle leases, clear pending/live terminal state, cancel provider work, or fragment an overlapping preparation. Only a different tuple performs authority replacement. - -#### Reconnect-finalization flow - -```ts -async function requestReconnect(authority) { - if (!isCurrentConnectedAuthority(authority)) return stale() - - // Authority replacement first settles all obsolete local leases and fences their late work. - coordinator.replaceAuthority(authority) - - // A missed disconnect can leave a non-null PTY from the old provider incarnation. - invalidateStaleDirectSshTargetPtyBindings(authority) - - // Terminal-critical and synchronous: no catalog, Git, lineage, or other target can gate it. - const retried = retryDirectSshTargetPanes(authority) - - // Relay flapping never delays terminal recovery, but it does damp full Git preparation. - if (!hasAuthorityBeenStableFor(authority, RELAY_LOST_STABILIZED_MS)) { - scheduleLatestAuthorityPreparation(authority, RELAY_LOST_STABILIZED_MS) - return terminalOnlyOutcome(retried, 'stabilizing') - } - - const input = await capturePreparationInput(authority, 'reconnect') - if (!input.ok) return terminalOnlyOutcome(retried, input.reason) - - const prepared = await prepare(input.value) - if (!prepared.token || !isCurrentConnectedAuthority(authority)) { - return combine(retried, prepared) - } - - // Catch tabs whose exact ownership or hydration became visible during this target's preparation. - const discoveredRetries = correctUnboundTerminals(authority, 'preparation-complete') - void syncRemoteWorkspaceAfterConnect(prepared.token) - return combine(retried + discoveredRetries, prepared) -} -``` - -The first terminal retry is complete before `capturePreparationInput` performs any await. Target B therefore retries even when target A has five slow provider requests. A same-authority duplicate runs bounded correction: healthy live bindings are no-ops, pending attempts are not duplicated, and failed/unbound tabs can re-arm. When workspace hydration completes, `finalizeHydratedTerminals` reruns against the current authority and handles newly hydrated or still-unbound tabs. - -Preparation for each target progresses independently. While authority remains current, it performs its host-scoped lineage read only after every repo task reaches a terminal state: `complete`, `non-authoritative`, final `timed-out`, `cancel-budget-exhausted`, or `rejected`. Authority-wide `canceled`/`stale` returns without lineage or a token. A first retryable timeout is the nonterminal `retrying` state and cannot release lineage, terminal correction, sync, or token creation. The target never waits for another target's repos or lineage. - -When authority rotates again within `RELAY_LOST_STABILIZED_MS` (currently five seconds), replace the delayed preparation with the latest authority and perform terminal finalization immediately. Only the authority that survives the stabilization window starts catalog/Git/lineage work. A same-authority wake during that window coalesces into the pending latest-authority preparation. This is damping, not an epoch-long result cache. - -#### Preparation-only flow - -`prepareOnly` runs catalog/worktree/lineage preparation and returns a token. It never invokes terminal retry and never starts reconnect sync. An unsolicited snapshot uses this mode, then applies that snapshot with the returned token. - -Reconnect and preparation-only requests may share exact overlapping catalog, repo, or lineage promises through ref-counted waiter leases. Superseding one consumer settles only its lease; it does not abort work still owned by another current consumer. They do not share finalization side effects. - -#### Input-scoped deduplication - -Preparation captures concrete inputs: exact authority pair, catalog revision, sorted repo/host refs, authoritative requirement, and snapshot revision when present. Only currently overlapping operations with identical relevant inputs join. - -Completed entries are removed immediately. Consequently: - -- a later same-authority wake rebroadcast runs another bounded refresh; -- a later snapshot revision runs another bounded preparation; -- a newly discovered repo changes the catalog revision/repo fingerprint and cannot join an older scope; -- a snapshot received during reconnect can share the still-running provider reads; and -- the reconnect call chain can pass its completed token directly to sync/apply without a second preparation. - -The token is an operation result, not a coordinator cache entry: - -```ts -type DirectSshPreparationToken = { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - authorityRequirement: PreparationInput['authorityRequirement'] - snapshotRevision: number | null - outcome: 'complete' | 'degraded' -} - -type SnapshotApplyToken = DirectSshPreparationToken & { - snapshotRevision: number -} -``` - -An unsolicited snapshot passes its revision into `prepareOnly`, so the returned token is already snapshot-bound. Reconnect preparation returns `snapshotRevision: null`; after `remoteWorkspace.get`, sync revalidates authority and creates a `SnapshotApplyToken` by copying the fetched revision onto that token. `applyRemoteWorkspaceSnapshot` accepts only `SnapshotApplyToken` and requires exact revision equality, so it cannot reuse preparation across incompatible snapshots. - -### 5. Fenced worktree, catalog, and lineage merges - -Refactor detected-worktree listing so provider acquisition and store mutation are separate: - -```ts -const listed = await listDetectedWorktreesForRepoCoalesced(request) -if (!isCurrentHostAuthority(request, listed)) return canceled('stale') - -// No code using listed.result may occur above this fence. -return mergeDetectedWorktrees(listed.result, request) -``` - -`mergeDetectedWorktrees` performs the sole renderer mutation for that result. It owns git-identity routing, review-link sanitation, purge state, and both worktree maps in one fenced path. It must not start an unfenced lineage refresh. - -The existing shared coalesced refresh key already includes `executionHostId` and remains unchanged. Add the full authority and concrete input revision only to the coordinator wrapper key. Do not describe host parsing as new key behavior and do not alter cross-subsystem coalescing. - -Host-scoped catalog and lineage functions likewise return immutable results. Each has one merge entry point that revalidates the current exact authority inside the Zustand updater. A stale result produces zero publications. - -Main uses the same pattern around provider work: - -```ts -const authority = resolveExactProvider(args) -const gitWorktrees = await authority.provider.listWorktrees(repo.path, { signal }) -if (!stillOwnsExactProvider(authority, args.expectedAuthority)) return stale() - -return buildAndCommitAuthoritativeResult(authority, gitWorktrees) -``` - -No prune, root-memory update, or metadata backfill occurs before `stillOwnsExactProvider`. - -### 6. Atomic terminal disconnect and retry - -Add terminal-slice actions: - -```ts -clearDirectSshTargetPtyBindings(targetId: string): number -invalidateStaleDirectSshTargetPtyBindings(authority: DirectSshAuthority): number -retryDirectSshTargetPanes(authority: DirectSshAuthority): number -settleDirectSshPaneRetry(result: DirectSshPaneRetryResult): void -``` - -Put pure projections in `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts`. - -`clearDirectSshTargetPtyBindings` traverses `tabsByWorktree` once, selects exact target scope, then applies the `tab.ptyId != null` predicate. For every affected tab the same atomic projection sets `tab.ptyId` to `null`, empties its `ptyIdsByTabId` entry, consumes `pendingActivationSpawn`, and removes pending Codex restart/restart-notice entries for the cleared live PTY. It preserves `lastKnownRelayPtyIdByTabId`, layouts, deferred SSH sessions, pending reconnect IDs, shutdown/suppression state, IDs, titles, generations, and agent state. It lazily clones only changed workspace arrays and maps, commits one patch, and triggers no activity, sorting, or metadata persistence. Tabs without a current `ptyId`, including those with `pendingActivationSpawn`, are untouched. - -`invalidateStaleDirectSshTargetPtyBindings` validates the authority inside the updater and applies that complete atomic projection to a non-null `ptyId` when its transient `ptyAuthorityByTabId` does not equal the current authority. Snapshot-imported or legacy bindings without current-authority provenance are wake hints, not live bindings. - -`retryDirectSshTargetPanes` validates the exact authority inside the updater, resolves scope from that same state snapshot, applies `shouldRetryPaneSpawnOnSshReconnect` plus stale-binding evidence, excludes only current live-success and pending-attempt tab IDs, and commits one `tabsByWorktree` patch. It records a unique tab-wide attempt outside the success ledger. `settleDirectSshPaneRetry` records current-authority success only after a live PTY binding is committed. A failed or timed-out first attempt rotates the tab once; an exhausted second attempt retains continuation authority for sibling callbacks but cannot start a third attempt. - -`clearTabPtyId` keeps genuine single-PTY exit semantics, but split recovery adds two exact-authority projections: promote an already-bound surviving PTY under the same lease, or preserve the lease and activation suppression across an empty-primary gap until a same-attempt sibling commits or settles. `syncPaneDetachPtyOwnership` likewise projects one current split lease and history to both resulting tabs without spawning, exiting, or changing authority, even when the pending attempt has not produced its first PTY. A pane mounted after the first sibling succeeds captures the retained live lease; every spawn callback rechecks the full authority pair and retires a stale provider PTY. Permanent target removal continues through `src/renderer/src/store/slices/ssh-target-cleanup.ts`, whose deletion of last-known and deferred liveness is invalid for a reconnectable disconnect. - -`applySshConnectionStateChange` receives an explicit origin and becomes orchestration: - -```ts -type SshStateApplyOrigin = 'push' | 'initial-hydration' - -const applySshConnectionStateChange = (targetId, state, origin: SshStateApplyOrigin) => { - const previous = getSshConnectionState(targetId) - setSshConnectionState(targetId, state) - - if (isTerminalFailure(state.status)) { - coordinator.invalidate(targetId) - clearRemoteDetectedAgents(targetId) - clearPortForwards(targetId) - setDetectedPorts(targetId, []) - clearDirectSshTargetPtyBindings(targetId) - return - } - - if (state.status === 'connected') { - if (!state.providerEpoch || state.connectionGeneration === undefined) { - void reconcileAuthorityOnce({ - targetId, - initiatingEventWatermark: getStateEventWatermark(targetId), - initiatingState: state, - initiatingOrigin: origin - }).then(applyAuthorityPatchIfStillCurrent) - } else { - const authority = { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - - if (origin === 'initial-hydration') { - coordinator.replaceAuthority(authority) - void coordinator.prepareOnly({ - ...authority, - authorityRequirement: 'required', - reason: 'initial-hydration' - }) - } else if ( - previous?.status !== 'connected' || - previous.providerEpoch !== state.providerEpoch || - previous.connectionGeneration !== state.connectionGeneration - ) { - void coordinator.requestReconnect(authority) - } else { - coordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareAndSyncWithoutHealthyTerminalRemount({ - ...authority, - reason: 'wake-refresh' - }) - } - } - } -} -``` - -Increment the per-target state-event watermark before applying every push event. `setSshConnectionState` must publish when either authority component changes. Reconciliation may only patch missing authority onto the still-current initiating state; it never enqueues a complete stale state reply. - -The terminal-failure cleanup order remains `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, then atomic PTY binding clear. Preserve the current port-broadcast race defense and detected-agent re-detection behavior. - -An already-connected same-authority rebroadcast is a wake refresh, not a healthy-pane remount. It performs bounded unbound/stale correction, fresh bounded preparation, and sync. Initial hydration always carries `origin: 'initial-hydration'` and uses `prepareOnly`; it is never inferred from the absence of `previous`. A bounded authority reconciliation retains the initiating origin, so filling a retained state's missing pair also cannot become a reconnect transition. Hydrating an already-live state must not remount healthy panes. Workspace session completion invokes `finalizeHydratedTerminals` only for reconnect authorities recorded during that hydration interval, but a failed/unbound attempt remains eligible for later same-authority triggers. - -## Remote-workspace data flows - -### Reconnect sync - -1. Reconnect finalizes qualifying terminals immediately. -2. Its target-scoped preparation returns a token. -3. Before awaiting `remoteWorkspace.get`, `syncRemoteWorkspaceAfterConnect(token)` captures whether the exact target currently has local tabs. This capture-before-await ordering is load-bearing. -4. The function validates the token, obtains the snapshot, and passes the same token to `applyRemoteWorkspaceSnapshot`. -5. Snapshot apply does not prepare again and is preparation-only with respect to coordinator retry; existing snapshot-driven `reconnectPersistedTerminals` behavior remains. -6. For `revision === 0`, mark hydration and publish the current local session only when the pre-await `hasLocalTabs` capture was true. Revalidate authority before upload. Do not recompute that predicate after preparation/hydration, which could overwrite a newer relay snapshot with locally imported state. -7. Snapshot projection and persisted-terminal reconnect receive only host-qualified worktree references from the token's exact target scope. They cannot reset, replace, remove, or reattach sibling SSH, local, WSL, or runtime-owned tabs or their PTY indexes, layouts, active selection, generations, retry state, or live-binding state, even when raw repo/worktree IDs or paths collide. - -### Unsolicited snapshot - -1. Capture current target authority and the incoming snapshot revision. -2. Call `prepareOnly` with those concrete inputs. -3. Wait for existing workspace-session hydration, bounded by the current 10-second deadline. -4. Capture the target terminal-recovery revision and same-ID local tab recovery fields before snapshot projection. -5. Revalidate the token, snapshot revision, arrival order, and recovery revision immediately before merge. If recovery advanced, rebase the projection on the latest local recovery fields rather than applying the stale captured copy. -6. Apply once as preparation-only. For a same-ID tab, preserve any newer local `generation`, pending attempt, successful current-authority binding, and terminal-recovery revision. Remote `generation` is not comparable across clients and cannot overwrite a local retry. Imported `ptyId`/pending reconnect data is a wake hint until `reconnectPersistedTerminals` settles and current-authority binding provenance is recorded. -7. Run the target-scoped, host-qualified `reconnectPersistedTerminals`, then `finalizeHydratedTerminals` for a reconnect authority. A failed reattach clears pending state and re-arms correction; a successful exact reattach atomically retires its pending attempt and records the current binding without an extra bump. -8. If path resolution still reports unknown worktrees, record a degraded result; do not loop unboundedly. - -A later same-authority snapshot always receives a new preparation attempt after earlier work completed. This preserves convergence when another client creates a worktree while the connection stays live. - -Remote snapshot identity is repo-qualified where the schema provides repo identity: resolve by `(executionHostId, repoId, normalizedPath)` and fail closed on ambiguity. For legacy path-only entries, retain the existing resolver only when the path has exactly one candidate in the exact target scope. Two worktrees at the same absolute host path were not proven producible, so this is a robustness invariant rather than a claim that current storage necessarily creates that collision. - -The existing `buildWorktreeByIdIndex` and `reconnectPersistedTerminals` path retains first-wins ID-only compatibility for its current callers. Direct-SSH snapshot apply must pass host-qualified worktree refs through a dedicated overload or pre-resolved map; it cannot hand raw IDs back to an ID-only lookup and recover authority afterward. - -## Failure handling - -- **Unknown authority:** reconcile once behind the per-target arrival watermark; then fail closed with a retryable diagnostic. Do not use a renderer counter or apply the reply's status. -- **Catalog timeout/failure:** retain cached exact-owner scope, record degradation, and continue target preparation and already-completed terminal recovery. -- **One repo is non-authoritative:** keep its safe metadata fallback separate from operational failure and do not authoritatively delete rows. -- **One repo times out:** the provider invocation settles locally and sends best-effort cancel. On the first retryable timeout, keep the logical repo task and target preparation barrier pending in `retrying`, then requeue once at the target lane tail. A second timeout is final and degrades the repo. If current-provider cancel debt denies retry admission, settle the logical task as terminal `cancel-budget-exhausted`; this releases one degraded preparation outcome rather than an early-success token or an indefinite wait. None of these states blocks another target's terminal retry. -- **One repo rejects:** locally settle it without retry unless classified by the existing narrow transient predicate; operational rejection remains distinct from timeout and non-authoritative data. -- **Lineage timeout/failure:** preserve current lineage, mark target preparation degraded, and continue sync with exact cached worktree scope. -- **Authority advance or target invalidation:** synchronously cancel queued work, settle obsolete local waiter leases, send exactly one cancellation for every affected in-flight provider request ID, rotate renderer in-flight inputs and terminal-attempt state, and reject late results at both main and renderer fences. The new authority begins terminal finalization without waiting for relay acknowledgement. -- **Relay flapping:** finalize terminals for every new authority, but replace/defer full preparation until the latest authority survives `RELAY_LOST_STABILIZED_MS`. -- **Missed disconnect/stale binding:** clear only PTY bindings whose transient binding authority is absent or old, retain last-known relay identifiers, then retry under the new authority. -- **Failed terminal spawn/reattach:** settle the attempt as failed, remove it from pending/success state, and allow rate-limited correction on later preparation, hydration, snapshot, or wake triggers. -- **Workspace hydration timeout:** set existing per-target sync error; do not undo terminal or worktree recovery. Snapshot merge never replaces a newer local recovery revision. -- **Coordinator stop:** cancel queued work, locally settle every waiter, send best-effort aborts for unshared provider calls, and return without waiting for relay acknowledgement. Effect cleanup stops coordinator after subscriptions and before disposing its dedicated scheduler. -- **Unknown owner:** preserve state, count it, and retry on later host-qualified catalog input. -- **Contradictory owner:** preserve state, report the conflicting provenance classes without identifiers, and do not retry or mutate that row until a later catalog revision changes the evidence. - -No failure path falls back to unbounded `Promise.all`. - -## Observability - -Emit one aggregate diagnostic per target operation, not per global wave and not per repo/tab. Use `[direct-ssh-reconnect]` for reconnect finalization and `[direct-ssh-prepare]` for preparation-only work. - -Fields: - -- mode and reason; -- terminal panes retried, stale bindings cleared, successful corrections, and terminal-finalization duration; -- catalog outcome and duration; -- repo tasks completed, non-authoritative, retrying, final timed-out, cancel-budget-exhausted, canceled, stale, and rejected; -- direct-scheduler queue-wait and provider-execution duration distributions; -- timeout retry count, local waiter settlements, cancel debt, and replacement admissions delayed by cancel debt; -- peak locally unsettled coordinator-owned detected-worktree concurrency and estimated late-work allowance; -- lineage outcome; -- Git-worktree, folder-workspace, ambiguous-owner, and contradictory-owner counts; -- overlapping request joins; -- authority rotations observed and preparations damped during flapping; -- total target-operation duration. - -Expected supersession/cancellation is debug-level and does not increment degraded/error metrics. Timeout is separate from queue wait and operational rejection. `fetchWorktrees === false` is not used as a failure proxy; the new discriminated result preserves non-authoritative versus rejected outcomes. - -Do not log target IDs, repo IDs, paths, labels, hosts, usernames, credential errors, snapshot content, raw request IDs, or terminal output. A stable per-session opaque target alias may correlate concurrent aggregate events and is discarded at process exit. - -The product event deliberately omits terminal correction failure/re-arm counts, concurrent non-coordinator call counts, and arrival-order discard counts because this implementation has no truthful production observation for them. It does not populate unobserved fields with constant zeroes. - -Typed product telemetry is implemented with one strict identifier-free aggregate event per target operation. Queue and provider percentiles are derived from that operation's real scheduler samples. The seven-day dogfood dashboard/query is operational follow-up outside this repository; this PR does not claim a dashboard artifact. Schema tests reject identifiers and keep queue wait, execution, timeout, operational rejection, cancellation, and stale results distinct. - -The concurrency metric is explicitly `coordinator_owned_direct_ssh_detected_worktree_concurrency`. It measures locally unsettled provider promises and says nothing about runtime lineage RPCs, sidebar's eight-worker pool, filesystem-event calls, catalog/lineage IPC, total application provider concurrency, non-coordinator same-connection calls, or relay handlers finishing after local cancel. - -## Tests - -This implementation registers the worktree scan-count, host/authority, timeout-barrier, and no-cross-host mutation oracles in the existing `git-worktree.refresh-event-semantics` gate. It extends `terminal-provider.ssh-remote-reattach-contract` for direct-SSH binding clear/retry, hydration, folder workspace, paired-close non-interference, and #8255 wake isolation. A new reliability gate is unnecessary because those existing gates own the lifecycle contracts. - -### Main/preload host and authority contract - -- Preload forwards `repoId`, `executionHostId`, the complete expected authority, and `providerRequestId` unchanged; main owns the deadline and never receives `waiterLeaseId`. -- Renderer host intent across local, direct SSH A/B, and runtime-alias catalog rows routes to the exact provider. Fabricated duplicate main-store rows still fail closed as defense in depth. -- Desktop main rejects a runtime execution host. -- Zero or multiple same-host matches fail closed; unqualified legacy calls fail closed when ownership is ambiguous. -- Compile-time fixtures cannot construct a direct-SSH `complete` result or `authoritative: true` lineage snapshot without both authority fields. Runtime admission rejects malformed wire values that omit either field. Local authoritative variants carry neither SSH field. -- A response echoes exact host and complete authority; renderer rejects any host, target, epoch, generation, provider request, or discriminant mismatch. -- Relay loss/replacement, transport loss/replacement, provider disposal, target readoption, and permanent removal rotate epoch and generation in one helper. -- A per-target generation counter exhaustion rolls the process generation scope, revokes every target's old authority and mutation token, and aborts every old-scope provider request before new-scope admission. -- Relay-only replacement rejects both old reconnect/provider work and an old `SshMutationExpectation`. -- A fresh `connected` authority is not broadcast until its provider is registered. -- An old provider result after any rotation performs no root-memory, lineage-prune, or metadata-backfill mutation. -- Cancellation and timeout reach the provider `AbortSignal` and multiplexer `rpc.cancel`. -- Duplicate repo IDs never trigger cross-host repo-prefix lineage pruning. -- Legacy lineage prunes under a unique repo owner, ambiguous legacy rows are preserved, and an authoritative scan backfills `meta.hostId`. -- `sshConnectionStatesEqual`, retained-payload admission, public projection, preload push/get, startup reconnect, runtime retained/client payloads, renderer/runtime stores, and state builders preserve both authority fields. -- Main-originated broadcasts always publish the complete authority pair. Malformed epoch-only or generation-only retained inputs are rejected; compatibility push/get inputs can only remain `authority-unknown` for bounded reconciliation and perform no authoritative mutation. One valid pair change publishes once and an exact duplicate is a no-op. -- Retained-state admission accepts a bounded valid epoch, rejects malformed/oversized epochs, and never strips a valid authority component. -- The web preload compatibility overload preserves requested-host echo for runtime reads; this does not enable direct-SSH coordination in paired web clients. -- A stale `ssh:getState` reply arriving after a disconnect/reconnect push cannot change status or fill authority; a same-watermark reply can fill only missing authority. -- Main host-catalog admission rejects a row whose explicit execution host contradicts its legacy connection host; it returns no authoritative rows instead of selecting or hiding that row by precedence. - -### Renderer worktree/catalog/lineage fences - -- The coordinator in-flight key differs by host, full authority, catalog revision, and authoritative requirement; the shared coalescer key remains unchanged. -- Inputs that differ only by `authorityRequirement` do not join, and the resulting token echoes that exact requirement. -- Exact overlapping requests join; the same key after completion runs again. -- An old result after disconnect/reconnect or relay-only replacement causes zero store publications. -- Git identity, hosted-review links, purge state, both worktree maps, and best-effort lineage remain byte-identical on stale results. -- Local, another SSH target, and runtime owners with the same repo ID remain unchanged. -- Target snapshot hydration and persisted-terminal reconnect use host-qualified worktree references, prune the retry/live/history ledgers of tabs authoritatively deleted from that exact scope, and leave retained or sibling SSH, local, WSL, folder, and runtime tabs, PTY indexes, layouts, active state, generations, and recovery ledgers unchanged. -- A renderer worktree result is admitted only while every current same-ID repo row still has valid, non-contradictory explicit/legacy ownership; malformed or contradictory provenance introduced during the provider await makes the result stale without store mutation. -- A target-scoped catalog fetch cannot be superseded by focused-runtime `fetchRepos`. -- Host-scoped lineage deletes a stale direct SSH row while preserving local, another SSH target, runtime, and unknown-owner rows. -- Runtime-focused UI state cannot redirect direct SSH catalog, worktree, or lineage ownership. -- An ID-only first-wins hydration index containing the same worktree ID under SSH A and SSH B is never authoritative for direct reconnect; a target-B snapshot reattaches only the host-qualified B row or fails closed. -- Pre-catalog direct SSH scope with a focused runtime treats focus fallback as ambiguous and still recovers tabs supported by explicit PTY/host provenance. -- An explicit `ssh:B` worktree with repo-derived `ssh:A` is `contradictory-owner` for both targets and remains byte-identical. The inverse mismatch, an SSH host plus explicit runtime owner, and conflicting folder group/repo connections fail the same way. -- Repo-derived ownership is accepted only when explicit worktree ownership is absent; adding a conflicting explicit owner converts the same row from accepted to preserved/contradictory without cross-host deletion. -- Git 2.25-compatible worktree fallbacks remain unchanged. - -### Direct SSH scheduler/coordinator - -- A singleton reconnect begins terminal finalization immediately without a collection delay. -- Target B terminal finalization completes while all five direct provider slots are occupied by target A. -- Target B's first repo starts after at most one bounded provider deadline when it arrives behind five A calls. -- Round-robin admission prevents a large target from continually reoccupying every released slot. -- Peak coordinator-owned detected-worktree calls is five; runtime/sidebar activity can raise total app concurrency without failing this assertion. -- Joined consumers receive distinct waiter lease IDs and one shared provider request ID. Canceling one settles only that lease while the other completes from the original call; no provider cancel is sent. -- Last-waiter release sends exactly one cancellation for that shared provider request ID. Authority invalidation sends exactly one cancellation for each affected in-flight provider request, including multi-repo preparation; no cancellation is sent merely because one lease leaves while another current-authority lease still owns that invocation. A waiter lease ID presented to main is rejected and cannot abort provider work. -- A coordinator lease joining sidebar/filesystem work cannot abort that work when the coordinator is superseded; the remaining non-coordinator lease keeps the provider request alive. -- A timeout sends `rpc.cancel`, settles locally, and releases its local slot without a relay acknowledgement. -- Repeated timeout replacements never exceed the two-call cancel-debt allowance; denied work settles as terminal `cancel-budget-exhausted` and is reported separately. -- After a first retryable timeout, the repo task is `retrying`: lineage calls, preparation tokens, sync, and snapshot apply remain at zero until the retry settles. -- A successful retry then releases one lineage read and one token; a second timeout or cancel-budget exhaustion releases a degraded result without starving another target. -- An exact-equal `replaceAuthority` call preserves every pending lease, live binding, and overlapping preparation and sends no cancellation. -- A same-authority reconnect rechecks terminals but does not rebump a healthy current-authority binding. -- A newly hydrated tab receives one retry later under the same authority. -- A tab whose exact ownership becomes visible during that target's preparation receives one retry without delaying the first terminal finalization. -- An authority advance with five old slow calls cancels queued work, settles/aborts old leases, starts new terminal finalization immediately, admits new work under cancel-debt rules, and permits zero old main/renderer mutations. -- A changed authority supersedes old target work by arrival, without numeric comparison. -- A same-authority connected rebroadcast performs bounded correction plus preparation/sync without retrying healthy terminals. -- A failed or timed-out pane on attempt one rotates the tab once; attempt-two failure cannot start attempt three or revoke continuation authority from siblings already settling. -- Three authority rotations inside the stabilization window perform three immediate terminal checks but only one full preparation for the final stable authority. -- `prepareOnly` never invokes terminal retry or reconnect sync. -- A preparation-only request shares exact in-flight repo work with reconnect preparation but not reconnect finalization. -- A completed preparation never suppresses a later same-authority wake or snapshot preparation. -- Stop locally settles queued/in-flight waiters without waiting for relay acknowledgement and prevents post-stop finalization. -- One system resume/browser-online event advances #8255 remote-runtime backoffs and direct-SSH wake preparation independently; runtime-owned SSH rows never enter the direct coordinator, and a direct tab already live under current authority is not double-bumped. - -### Atomic terminal recovery - -- Many live tabs/worktrees clear in one store publication; a second clear is a true no-op. -- Only tabs with `tab.ptyId != null` are changed. -- A null-PTY tab with `pendingActivationSpawn` remains byte-identical. -- A live-PTY tab consumes `pendingActivationSpawn` in the same patch that clears its tab and split-pane PTY indexes. -- `lastKnownRelayPtyIdByTabId` survives and the `#9911` orphan predicate remains reconnectable. -- Live split-pane PTY indexes and Codex restart metadata clear. -- Layouts, deferred sessions, pending reconnect IDs, shutdown/suppression state, titles, and agent state remain unchanged. -- No worktree activity, sorting, or metadata persistence occurs. -- Exact target A clear/retry leaves target B, local, WSL, floating, and runtime-owned tabs unchanged even with duplicate repo IDs. -- Exact direct SSH folder workspaces clear/retry; mixed, ambiguous, and runtime folders do not. -- A parsed target PTY recovers a stale-catalog tab only without contradictory ownership. -- A relay/provider replacement after a missed disconnect clears a pre-authority `ptyId` and retries it while preserving last-known relay IDs. -- Binding provenance for another authority or no provenance is stale; a successful current-authority spawn becomes live and suppresses healthy correction. -- One authority chain permits at most two automatic correction attempts even when each timeout exceeds 30 seconds; later same-authority triggers remain exhausted until authority replacement. -- Rejected stale/mismatched acknowledgements preserve every store map and publish nothing. Concurrent split spawn or reattach acknowledgements share one exact attempt; the first establishes the fallback/live lease, and both already-mounted and post-success-mounted siblings join it. -- Primary exit before a sibling binds preserves the exact continuation lease and activation suppression; the sibling then becomes the fallback without a corrective remount. -- Primary and non-primary split detach both preserve exact authority and retry history on the surviving source and detached destination; an all-null pending-only detach preserves the lease before either side binds, and a same-authority correction leaves both live. -- Disconnect retains exact existing ordering and effects for `clearRemoteDetectedAgents`, `clearPortForwards`, `setDetectedPorts([])`, and atomic PTY clear. -- Disconnect and reconnect emit zero paired `session.tabs.close`/`closeLifecycle`, provider shutdowns, or process signals. -- A manually parked direct-SSH worktree follows the same store patch and retry eligibility without invoking parking, close, or layout mutation. - -### Remote-workspace and hydration integration - -- Connect plus sync performs one logical preparation and passes its token through a nonempty snapshot apply without preparing again. -- An unsolicited snapshot joins exact in-flight preparation but starts a new preparation after that work settles. -- A later same-authority snapshot referencing a newly created worktree resolves and imports its tabs. -- Preparation-only snapshot handling never bumps terminal generations. -- Preparation-only means no coordinator generation bump; existing snapshot-driven terminal reattach remains and is finalized afterward. -- Initial hydration with no previous renderer state follows the explicit `initial-hydration` origin into `prepareOnly` and performs zero reconnect retries; reconciliation retains that origin. -- Reconnect finalized before session hydration retries newly hydrated tabs exactly once afterward. -- Immediate finalization bumps a tab, then a snapshot with the same stable tab ID and an older/absent generation cannot reduce the local generation or suppress correction. -- Snapshot hydration that lands after a newer local retry preserves the newer terminal-recovery revision, pending attempt, and current binding provenance. -- Snapshot-imported `ptyId` without current-authority live evidence remains retry-eligible; successful exact target-scoped `reconnectPersistedTerminals` retires the pending attempt and records it live, while failure re-arms it. -- Snapshot projection and reconnect are host-qualified end to end; another SSH target, local, WSL, and runtime-owned state remain byte-identical despite colliding raw IDs or paths. -- Same-authority wake rebroadcast performs bounded fresh discovery. -- Revision-zero sync captures `hasLocalTabs` before `remoteWorkspace.get`, uploads only on that capture, and revalidates authority before publish. -- Snapshot apply rejects a token whose `snapshotRevision` differs, and reconnect sync can create a `SnapshotApplyToken` only from the snapshot fetched by that same fenced operation. -- Repo-qualified snapshot mapping keeps same-named repo paths isolated; a legacy path-only entry applies only with one exact-target candidate and otherwise fails closed. -- Folder workspace keys never enter snapshot projection. -- Hydration timeout does not undo terminal recovery. - -### Performance and diagnostics - -Seed direct SSH targets, a runtime environment, sidebar refreshes, worktrees, folder workspaces, split tabs, and large terminal maps. Assert: - -- coordinator-owned direct SSH detected-worktree concurrency never exceeds five; -- runtime and sidebar work are excluded from, and may exceed, that scoped count; -- terminal retry is submitted before any provider task and uses one publication; -- disconnect uses one publication and schedules session persistence once; -- queue wait, provider duration, timeout, and cancellation are distinct; -- canceled/stale outcomes do not console-error or increment degraded counts; -- diagnostics contain counts/durations plus only an ephemeral target alias; -- the typed telemetry schema rejects identifiers and emits observed queue/provider distributions, timeout/retry/cancel-debt counts, flapping damping, and successful correction results; and -- the adapter computes bounded p50/p95/p99 values from each operation's real scheduler samples. - -## Implementation map - -- `src/main/ssh/ssh-provider-authority.ts` composes epoch issuance with `ssh-connection-generation.ts`; `src/main/ipc/ssh.ts` rotates on every provider transition, registers before `connected`, and publishes the pair. -- `src/shared/ssh-types.ts`, `ssh-retained-payload-admission.ts`, `runtime-client-events.ts`, and public SSH state projection define, validate, and retain the complete authority. `src/preload/api-types.ts`, `src/preload/index.ts`, startup reconnect, runtime SSH state, and web mutation readers copy it unchanged. -- `src/renderer/src/store/slices/ssh-target-cleanup.ts` and `ssh.ts` make the authority pair equality-significant and patch-preserving. The state-event/reconciliation code in `useIpcEvents` owns arrival watermarks. -- `src/main/ipc/worktrees.ts` and the provider authority module own exact host/provider resolution, the provider-request registry, main deadlines/aborts, post-await fences, qualified lineage pruning, and host metadata backfill. Main has no waiter-lease concept. -- `src/renderer/src/store/slices/detected-worktree-refresh-leases.ts` owns the existing public coalescer key, compatible provider invocation entries, independently generated provider request and waiter lease IDs, ref-counted last-waiter cancellation, and per-lease settlement for every caller. -- `src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts` acquires shared leases and owns the five local slots, target round-robin, authority/revision wrapper key, nonterminal `retrying` state, one timeout retry, and cancel-debt allowance. -- `src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts` owns per-target authority replacement, preparation waiters, stabilization damping, preparation barriers/tokens, pending terminal attempts, success ledger, and bounded corrective triggers. -- `src/renderer/src/lib/direct-ssh-target-scope.ts` uses explicit provenance for Git and folder scope; it never reads focused-runtime ownership. -- Terminal slice actions and `src/renderer/src/store/slices/direct-ssh-terminal-recovery.ts` own atomic clear, stale-binding invalidation, transient PTY authority, attempt settlement, and single-publication retry projection. -- `src/renderer/src/hooks/remote-workspace-target-sync.ts` owns capture-before-await revision-zero push, token/revision fences, repo-qualified legacy-safe resolution, local recovery preservation, snapshot-driven reattach, and post-hydration finalization. -- Typed aggregate telemetry owns the privacy schema, per-operation distributions, and fail-soft histogram emission. External dogfood dashboard/query configuration remains an operational follow-up. - -## Rollout - -The implementation preserves this dependency order: - -1. Add the opaque epoch and `rotateSshProviderAuthority`, expand `connectionGeneration` rotation to the same transition set, and inventory every state equality/copy/preload/retained/reconciliation boundary. Land complete-pair publication, malformed partial-authority rejection, retained-admission, stale-reconciliation, provider-before-broadcast, and old-mutation-expectation tests before any coordinator routing. -2. Add host-qualified detected-worktree and lineage IPC, discriminated authoritative response admission, exact main provider selection, main-owned 30-second deadline, provider-request cancellation, and main pre-mutation fences. Preserve the web/runtime overload without enabling direct-SSH web coordination. Keep the coordinator disabled. -3. Refactor renderer worktree/catalog/lineage reads to immutable results with pre-mutation full-authority fences. Add the host-scoped catalog lane independent of focused-runtime supersession, all-provenance contradiction rejection, legacy lineage host backfill, and shared coalescer leases with distinct waiter/provider IDs. -4. Add exact target scope, atomic disconnect clear, stale-authority binding invalidation, retry-attempt settlement, and transient binding provenance. Switch disconnect and reconnect terminal handling together so Git/folder clear-retry symmetry and detected-agent/port cleanup remain intact in every commit. -5. Add the dedicated fair direct SSH scheduler and per-target coordinator with local-settlement semantics, the first-timeout preparation barrier, one timeout retry, cancel-debt admission, authority-advance invalidation, same-authority correction, and flapping damping. Route connected events through reconnect finalization; leave #8255 remote-runtime recovery and runtime-owned SSH rows unchanged. -6. Extract `prepareRemoteWorkspaceTarget`, `syncRemoteWorkspaceAfterConnect`, and `applyRemoteWorkspaceSnapshot` into `src/renderer/src/hooks/remote-workspace-target-sync.ts`. Split preparation-only from reconnect mode, pass the full operation token through revision-zero push and snapshot apply, preserve newer local recovery state, finalize imported terminal hints, and remove repeated preparation calls. -7. Enable typed aggregate diagnostics/telemetry and dogfood with renderer-catalog owner aliasing, many direct targets, an active focused runtime, sidebar activity, folder workspaces, relay-only flapping, timeout/retry/cancel debt, missed disconnects, failed pane spawns, arrival-order races, system resume/browser online events, and cross-device snapshot changes. - -Stage 5 is gated on stages 2–4. The coordinator must never call catalog or lineage APIs whose ownership depends on `settings.activeRuntimeEnvironmentId`; a partial coordinator-first rollout is forbidden. System-resume/browser-online recovery must not double-bump a tab already live under the direct SSH authority ledger. - -Release checks: - -- zero host/authority mismatch accepted at either main or renderer mutation fences; -- zero authority component dropped at equality, retained, preload, startup, public-state, or reconciliation boundaries; -- zero authoritative direct-SSH result constructible or admitted without the full authority pair; -- no cross-host provider selection in duplicate-ID integration tests; -- no contradictory provenance accepted by OR-matching or repo fallback; -- terminal finalization is scheduled before provider discovery and is not delayed by another target or runtime/sidebar work; -- p95/p99 direct scheduler queue wait and provider duration, plus timeout/retry/cancel-debt rates, are reported separately from same-relay non-coordinator traffic; -- peak coordinator-owned direct SSH detected-worktree concurrency is at most five; -- waiter cancellation is lease-local, last-waiter release sends one cancellation for its provider request, authority invalidation sends exactly one per affected in-flight provider request, provider cancellation settles without relay acknowledgement, and cancel debt remains bounded; -- no lineage read, preparation token, or sync starts while a first timed-out repo remains retrying; -- authority advance invalidates old queued/in-flight work and relay flapping produces only one stable preparation wave; -- missed disconnect, failed spawn, and hydration overwrite cases converge through bounded same-authority correction; -- later same-authority snapshots containing new worktrees converge; -- revision-zero push preserves capture-before-await ordering and stale reconciliation cannot resurrect connected state; -- direct SSH lineage deletion is host-correct; and -- Git and folder terminal overlays clear and retry symmetrically while port and detected-agent cleanup remains intact. - -Rollback disables coordinator routing with build-time `VITE_DIRECT_SSH_RECONNECT_COORDINATOR=false` or session key `orca.directSshReconnectCoordinator.enabled=false` while retaining composed authority rotation, authority-boundary preservation, host-qualified IPC, mutation fences, and atomic terminal actions. The fallback reconnect path uses the dedicated bounded scheduler and preserves port/detected-agent cleanup; it does not restore host-blind or unbounded `Promise.all`. - -## Cross-platform and compatibility - -- Use existing execution-host, folder-workspace, workspace-key, SSH PTY-ID, and path utilities. Do not concatenate execution-host IDs or parse filesystem paths. -- The design introduces no keyboard behavior or platform-specific UI. -- Direct SSH on Windows, macOS, Linux, and WSL follows the same host/authority rules. -- Cancellation uses typed provider request IDs over Electron IPC and existing `AbortSignal` support; waiter lease IDs remain renderer-local, and no OS signal semantics or new cancel-ack protocol crosses the wire. -- No Git command, option, parser, or native dependency changes. Git 2.25 compatibility and capability fallbacks remain authoritative. -- Remote runtimes require no server upgrade. Runtime-host requests continue through the existing runtime RPC route. -- Keep new modules within normal line limits; do not add or widen a `max-lines` disable. - -## Rejected alternatives and overreach - -### Treat in-flight coalescing as collapsing the current sequential scan count - -Rejected. The connected refresh, sync preparation, and snapshot preparation await one another. Their provider reads do not overlap, so the current single-flight map has no live promise for the later wave to join. It can reduce unrelated overlapping calls, but does not invalidate the `2R`/`3R` sequential-path diagnosis. - -### Use either main connection generation or provider epoch as an independent clock - -Rejected. The renderer fallback counter has different ownership and is never authoritative. The existing main generation and new opaque epoch are composed by one rotation helper and transition set: mutation consumers compare the generation, provider/recovery consumers compare the pair, and no producer may advance them independently. - -### Cache completed preparation for an entire authority - -Rejected. Stable connections can receive wake refreshes and later snapshots referencing newly created worktrees. Only exact overlapping input work is shared; completed work never suppresses later inputs. - -### Use one global wave and FIFO for direct SSH plus runtime discovery - -Rejected. It couples terminal recovery and target preparation to unrelated queues and timeouts. Direct terminal finalization is synchronous per target; direct provider work uses a dedicated fair bounded scheduler. - -### Let preparation-only reuse reconnect finalization - -Rejected. Snapshot preparation must never initiate coordinator retry or reconnect sync. Existing snapshot-driven persisted-terminal reattach remains part of apply and is reconciled with current-authority binding evidence afterward. Only immutable catalog/repo/lineage reads may be shared. - -### Fence only before the final worktree-map merge - -Rejected. Main lineage/metadata changes and renderer git-identity routing occur earlier. Every authoritative side effect is downstream of an exact host/authority fence. - -### Use `{ forceLocalOwner: true }` for direct SSH lineage - -Rejected. It cannot authoritatively replace an SSH host scope and leaves deleted SSH lineage behind. The wire response and renderer replacement scope must both name the exact execution host. - -### Reuse the component sidebar single-flight coalescer - -Rejected. It is component-scoped, fire-and-forget, and lacks provider-authority, cancellation, terminal, and preparation semantics. The coordinator instead acquires leases from the shared detected-worktree provider coalescer while preserving its public key shape. - -### Replace direct `Promise.all` with `refreshRuntimeProjectWorktrees` - -Rejected. It bounds only one invocation, remains host-blind at the desktop IPC boundary, and does not remove repeated preparation. - -### Use one scheduler per SSH target - -Rejected. It permits `5 × targetCount` locally unsettled provider calls. One dedicated fair scheduler provides a truthful five-local-call bound without a cross-target completion barrier; cancel debt states the separate late-relay allowance. - -### Wait for a relay cancellation acknowledgement - -Rejected. `rpc.cancel` is a notification and canceled handlers intentionally send no response. Local waiter/provider settlement releases scheduler ownership; late effects remain authority-fenced and replacement admission is bounded by cancel debt. - -### Expand direct SSH coordination to the paired web client - -Rejected. Paired web clients cannot subscribe to the desktop direct-SSH state path. Preserve shared overload compatibility and host echoes, but do not create a new direct-SSH web transport to address an unreachable version of the shim claim. - -### Apply a fixed collection debounce - -Rejected. It taxes the singleton common case and is unnecessary when exact overlapping work already coalesces. Fair incremental admission handles multi-target bursts. - -### Call `fetchAllWorktrees` - -Rejected. It refreshes unrelated hosts, weakens failure isolation, and collides with the separate sidebar surface. - -### Reuse permanent target-removal cleanup - -Rejected. Removal intentionally deletes last-known and deferred liveness. Reconnectable disconnect must preserve them. - -### Batch repeated `clearTabPtyId` calls - -Rejected. React batching does not remove Zustand updater work, global map cloning, activity writes, or session debounce resets. - -### Move reconnect recovery into main - -Rejected. Main owns provider authority and mutation fences; renderer owns tab bindings, session projection, and Zustand updates. The host/authority boundary is the smaller ownership correction. diff --git a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md b/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md deleted file mode 100644 index 18ceea5bff2..00000000000 --- a/docs/reference/plans/2026-07-28-orchestration-seamless-update-migration.md +++ /dev/null @@ -1,576 +0,0 @@ -# Preserve active orchestration work across contract updates - -Status: implementation validation - -Date: 2026-07-28 - -## Decision - -Do not block updates, restart active workers, or revive the retired scheduler. - -On first launch after the contract update, atomically adopt the durable -pre-Run graph into one ordinary lightweight Run. Preserve every Task, -Dispatch, message, gate, terminal, PTY, process, worktree, and prompt -identifier. Mark only the pre-update Dispatch attempts as legacy-contract -attempts and accept the exact command shapes already pasted into those -workers through a narrow adapter. - -This is state migration plus a per-Dispatch protocol adapter. It is not a -second scheduler: - -- the migrated Run owns the preserved graph and coordinator inbox; -- legacy attempts keep their original protocol until they settle; -- every new attempt uses the current protocol, even on a preserved Task; and -- no placement, polling, concurrency, or deadline loop is resurrected. - -## User-visible outcome - -- The update installs immediately. -- Existing coordinator and worker terminals keep the same process, PTY, - worktree, prompt, Task ID, Dispatch ID, and history. -- Orca does not inject text, focus a tab, or start a replacement editor. -- The legacy graph appears in an automatically adopted, normal Run instead of - being stranded in `run_legacy_local`. -- An original live worker with retained launch proof can keep using the - `heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its - existing prompt. -- An original live coordinator can keep listing and settling preserved Tasks, - dispatch remaining work with current commands, check direct legacy mail, and - use the pre-cutover `reply` CLI guidance. -- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and - unmistakably read-only. They never advertise Reply or Ack actions. -- A pre-update WSL process whose older launcher did not propagate the hidden - token remains lifecycle read-only. Its assignment, process, terminal, and - filesystem work remain valid and inspectable. - -The retired `orchestration run`, `coordinator-start`, and `coordinator-stop` -scheduler commands remain retired. Their in-memory loop cannot be reconstructed -truthfully. The coordinator agent and all durable work remain accessible; it -explicitly dispatches any ready work that was never assigned before the update. - -## Evidence and constraints - -- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and - `decision_gates` by assigning them to `run_legacy_local`. -- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming - `check` shapes. It omitted `worker_done --outcome`; a subject equal to - `Failed` or beginning `Failed:` represented failure. Its escalation carried - `taskId` but no `dispatchId`. -- `reply` existed in the old CLI and message formatter guidance, not in that - pinned worker prompt. -- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and - provider `listProcesses()` does not expose the launch token. Handles and - pane IDs alone therefore cannot authorize compatibility. -- Managed hooks source the owner-only endpoint file on every invocation, so an - old process reaches the new hook server after restart. The hook cache retains - the prior authenticated pane/launch-token association and distinguishes - hydrated rows from events observed by the current runtime. -- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and - a running process cannot acquire it retroactively. -- The CLI transport mutation receipt is keyed by rotating runtime - authentication. It cannot deduplicate a legacy mutation across restart. -- Current invalid-capability lifecycle sends deliberately remain as auditable - rejected messages. Compatibility must not silently change that behavior. -- PR #11142 fixes background-tab materialization for newly dispatched workers. - It does not recover a pre-fix worker whose PTY survived an update after its - renderer tab binding was lost; workspace activation can otherwise mistake - that worker for a sleeping provider session and launch `codex resume`. -- The packaged Windows launcher keeps its Electron-as-Node child alive for the - lifetime of a blocking CLI command. A seamless design cannot assume that a - bounded RPC wait releases the installed executable. - -## Non-negotiable invariants - -1. Adoption never spawns, writes, signals, stops, focuses, or injects a - terminal and never mutates the assigned filesystem. It may restore one - persisted background tab/leaf binding for the exact already-live PTY. -2. Every durable row and identifier survives adoption; only its owning Run and - explicit protocol metadata change. -3. Compatibility is write-once per Dispatch. Migration never classifies a - current retry as legacy. -4. A legacy mutation requires one uniquely resolved legacy attempt or - coordinator principal plus current live proof of its original process tree. -5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote - attachments never create authority. -6. Current Dispatch behavior remains unchanged, including auditable rejected - lifecycle messages, capabilities, process incarnation, Run Delivery, and - question semantics. -7. Legacy direct mail and current Run Delivery are disjoint, even when their - Dispatches share one migrated Task and Run. -8. A B-era disconnect may replay accepted work; it may not lose mail or create - a second question, reply, completion, or settlement. A cut after an A-era - ask was already answered is explicitly ambiguous because A persisted no - invocation identity; recovery replays its answer through legacy check before - a same-text question is asked again. -9. Different reply bodies for the same source and principal conflict instead - of creating multiple replies. -10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows - follow the same proof rules. Missing proof degrades to inspection without - invalidating the retained assignment or filesystem work. - -## Storage and atomic adoption - -Raise the schema version and add: - -```text -dispatch_contexts.contract_version INTEGER NOT NULL -dispatch_contexts.launch_token_hash TEXT -messages.delivery_contract TEXT NOT NULL - CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only')) -legacy_adoptions( - source_run_id PRIMARY KEY, - adopted_run_id UNIQUE NOT NULL, - scheduler_state_lost INTEGER NOT NULL, - adopted_at -) -legacy_compatibility_principals( - id PRIMARY KEY, - run_id NOT NULL, - dispatch_id, - role CHECK(role IN ('worker', 'coordinator')), - host_scope NOT NULL, - terminal_handle NOT NULL, - pane_key NOT NULL, - launch_token_hash NOT NULL, - process_incarnation, - status CHECK(status IN ('committed', 'settled', 'revoked')), - UNIQUE(role, run_id, dispatch_id) -) -legacy_operation_receipts( - principal_id, - operation_key, - method, - payload_hash, - effect_id, - response_json, - completed_at, - PRIMARY KEY(principal_id, operation_key) -) -legacy_mail_receipts( - principal_id, - message_id, - acknowledged_at, - PRIMARY KEY(principal_id, message_id) -) -``` - -The schema migration runs in one `BEGIN IMMEDIATE` transaction: - -1. create a persisted random Run ID and `legacy_adoptions` marker if one does - not already exist; -2. create an ordinary `legacy = 0` Run with a recovery objective; -3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages, - and question threads to it without changing their IDs, sequence, status, or - content; -4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection - rows `audit_only`; -5. mark a Dispatch legacy only when it predates the contract column and lacks - a current dispatch capability; and -6. fence any impossible outstanding legacy Delivery, move retained Delivery - history to the adopted Run, assert every Task/Dispatch/gate/message/question/ - Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only - audit tombstone. - -Every new Dispatch and message writes the current contract explicitly. -Repeated startup, a partially initialized fixture, and WAL recovery reuse the -persisted adoption marker. Reset and fresh-database paths cannot synthesize -legacy authority. - -## Durable principals and live authority - -After the hook server and PTY inventory are ready, a one-shot adoption service -may commit compatibility principals. It does not mutate terminal processes or -input. - -Before renderer hydration, legacy Dispatch evidence also fences automatic -provider resume for the exact sleeping pane. Once the relevant local daemon or -SSH relay has authoritative inventory, Orca intersects the durable handle, -pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match -is adopted into its original tab/leaf as inactive background state while the -coordinator tab remains active. Missing, stale, reused, cross-host, or -ambiguous evidence stays fenced and inspectable. Ordinary provider resume is -unblocked only when authoritative inventory proves that exact PTY exited. - -At hook-server startup, copy the validated hydrated status rows into an -immutable main-process-only startup snapshot before the server listens or -binds the new listener. Later live events update a separate current-runtime -observation map and cannot overwrite the pre-update commitment source. A -current event is preferred but is not required before the worker's first -post-restart lifecycle command when controller inventory proves the exact -original PTY and process incarnation are live. - -For each candidate it resolves the union of: - -- durable Dispatch assignee and pane; -- equivalent-pane aliases; -- remote attachment and host ownership; -- coordinator candidates from active legacy coordinator rows, Task creator - handles, Dispatch coordinator/parent provenance, and direct-message - sender/recipient history; -- the restored terminal inventory and process incarnation; and -- the hydrated, previously authenticated hook pane/launch-token association. - -The candidate commits only when that union identifies exactly one role and -row. The orchestration database stores a SHA-256 token commitment, never the -raw token. The existing owner-only hook status cache is an input to the -one-time commitment, but a hydrated status is not enough to mutate. - -Every compatibility call then requires: - -- a hidden launch token and pane inherited by the calling process; -- the immutable pre-update hook commitment, plus either a matching - current-runtime hook event or exact current controller proof of the original - PTY and process incarnation; -- a live PTY with the committed handle, host scope, and process incarnation; -- the committed principal and exact active legacy Dispatch or adopted Run; and -- an operation-specific unique match. - -The current hook event or exact controller inventory proves that the process -survived the restart; equality with the pre-update commitment proves it is the -same launch. If the hook cache was absent, the provider cannot report the -proof, the token changed, or any candidate is ambiguous, the row remains -read-only. - -For new Dispatches, persist the launch-token hash at dispatch time so later -contract updates do not depend on hook-cache recovery. Never return or log raw -tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON -redaction tests. - -Host scope is runtime-owned: - -- local: stable local execution-host identity; -- WSL: stable native execution-host identity plus distro; -- SSH: saved-host authority plus remote pane/process identity; and -- federated runtime: authenticated peer fingerprint plus remote attachment. - -`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH -fallback must carry the hidden evidence without exposing it in command text or -logs. A local runtime never accepts a raw remote token as local authority. -Pre-update WSL processes without the token cannot use the lifecycle adapter. - -For SSH, the host passthrough and in-process fallback stamp the saved target, -`SshRelaySession` connection incarnation, and attachment provenance from the -authenticated runtime channel; caller environment cannot choose them. WSL -stamps the runtime-owned native host and distro in both the full bridge and -fallback bridge. The verifier intersects those stamps with the committed host -scope before considering token or pane evidence. - -## Mixed-contract Run and mail routing - -The adopted Run is a normal current Run. Tasks belong to it, so a current retry -of a preserved Task naturally routes to the same Run without a cross-Run Task -reference. - -Message delivery is selected by `messages.delivery_contract`, not by Run ID: - -- old and adapter-created direct messages are `legacy_direct`; -- current sends are `current_delivery`; and -- migration rejections and non-actionable history are `audit_only`. - -Current Run Delivery creation, filtering, waiting, and acknowledgment select -only `current_delivery`. Legacy `check` selects only the exact principal's -`legacy_direct` address. History and inbox may show all three with explicit -labels but never attach actions to `audit_only`. Mixed legacy/current attempts -under one Task are covered across send, ask, check, reply, and settlement. - -The original coordinator is automatically associated with the adopted Run -after its principal proves live. `run-use` may bind that same principal without -converting legacy Dispatches. A different coordinator cannot silently take -over while a legacy attempt is active; an explicit recovery takeover is a -separate current-contract action and revokes legacy coordinator mutation -authority before rebinding. - -When that coordinator uses old direct `check`, the adapter also performs a -non-consuming existence check for `current_delivery` mail. It never mixes the -messages into legacy output; it prints the exact current Run check command and, -after that command creates a Delivery, the exact acknowledgment form. This -keeps an unchanged coordinator informed about current retries without -weakening current Delivery semantics. - -## Exact legacy adapter - -The CLI keeps accepting the old flags. It sends a hidden compatibility -envelope; the runtime, not the CLI, decides whether legacy behavior applies. - -Version B generates a random hidden `compatibilityInvocationId` once per CLI -process and reuses it for every transport reconnect by that invocation. -`legacy_operation_receipts.operation_key` is this invocation ID for B calls. -The method and canonical payload hash must also match. - -Attested compatibility calls bypass the rotating-auth generic mutation ledger -after the contract fence routes them to the adapter. Their only dedupe boundary -is the compatibility-principal receipt transaction described here; current -calls continue through the existing generic ledger unchanged. - -Version A had no invocation ID. Cross-cutover reconstruction is therefore -limited to effects with a durable semantic identity: final settlement per -Dispatch, still-pending ask per normalized question/options/recipient, reply -per source/principal/body, and read recovery per message. Heartbeat and -escalation remain at-least-once across an A-to-B manual retry because -collapsing identical commands could suppress a legitimate later event. Tests -and guidance do not promise cross-cutover exactly-once behavior for those -repeatable signals. - -An A-era ask that was already answered before its output was lost is also -ambiguous. B does not guess whether the same text means retry or a new -question. Its answer is in the durable recovery cohort and the error tells the -agent to run the exact legacy check command first. After that answer is printed -and acknowledged, a new B invocation with identical text creates a new -question. If multiple identical A threads exist, ask fails ambiguous and -recovery check drains them by message sequence; no arbitrary thread is chosen. - -### Lifecycle `send` - -- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then - intersect them with the verified worker principal. -- `escalation`: resolve Task + verified worker principal to exactly one active - legacy Dispatch because the old prompt supplied no Dispatch ID. -- Preserve the old direct coordinator recipient after verifying it belongs to - the adopted Run principal. -- Infer missing `worker_done` outcome as `failed` only when the trimmed subject - is `Failed` or begins `Failed:`; otherwise infer `succeeded`. -- Normalize to the current internal settlement model. - -Compatibility validation happens before message insertion. Current lifecycle -handling is not refactored through this prevalidation: its existing -invalid-capability audit message behavior remains locked by regression tests. - -The DB commits the direct message, lifecycle effect, and compatibility receipt -in one transaction. If a pre-update `worker_done` already settled the Dispatch, -the adapter reconstructs the matching semantic receipt from the existing -message and settlement. The persisted A settlement is authoritative: the -pinned A reconciler recorded even a `Failed:` subject as completed, so B -returns that original completed result rather than retroactively changing Task -or dependency state. Only a not-yet-accepted B call applies the corrected -failure inference. A conflicting retry fails. - -### `check` - -Legacy check is at-least-once: - -1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs - without changing them; -2. the CLI prints the complete response; and -3. a hidden ack atomically records receipts and marks those exact rows read. - -A cut after read and before ack replays the same IDs. `--peek` and `--all` -remain read-only. - -Version A could mark a message read before losing its response. On adoption, -every eligible read direct message connected to a still-active legacy attempt -and lacking a post-adoption receipt is inserted into a durable recovery cohort -using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains -that stable cohort in bounded sequence-ordered pages before ordinary unread -mail. Each page is labeled -`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print -ack, and then advances. The cohort never grows after adoption, so every -eligible row is covered without an unbounded response. Settled-at-cutover and -unrelated historical mail do not enter it. - -### `ask` - -Legacy ask is an idempotent mutation followed by a resumable read: - -1. create or find the direct legacy gate/question by verified Dispatch, - normalized question, options, and recipient; -2. atomically commit its message/thread plus operation receipt and return the - stable question ID; -3. wait in bounded read-only slices using that ID; and -4. after printing the answer, hidden-ack that exact answer. - -On first adoption, existing pending decision-gate threads are indexed into the -same semantic identity. A retry resumes them instead of creating another gate. - -### `reply` - -Legacy reply requires the source thread, the verified coordinator principal, -and a unique question owned by the adopted Run. One transaction inserts the -reply, resolves the exact gate, marks the exact source, and completes the -receipt. - -An existing pre-update reply with the same source, principal, and body is -reconstructed as success. A second body for the same source/principal is a -conflict, not another reply. - -### Coordinator and Task commands - -After automatic binding, unchanged Task listing, Task settlement, dispatch, -and gate inspection use the adopted Run. A new attempt receives a current -preamble, capability, launch-token commitment, and current message contract. -No unrelated Task can be created until the migrated coordinator binding is -proven or explicitly taken over. - -## Windows update behavior - -No compatibility mutation holds a DB transaction while waiting. Windows -legacy `ask` commits the question, prints its stable ID and exact resume -command, then exits before a long wait whenever the installed launcher cannot -prove that the executable is replaceable. - -The native packaged launcher sets an internal -`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child. -After the DB commit, legacy `ask` writes and flushes the stable question ID and -the exact resume command, then exits with a documented `75` resume-required -status. The command name comes only from the validated inherited -`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or -`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather -than rendered. The launcher propagates status 75. No long wait starts on this -path. - -This compatibility path makes the accepted question recoverable without -claiming Windows updater or uninstaller process ownership. Generic executable -drain, replacement, and uninstall behavior remains separate updater -reliability work and requires its own Windows artifact proof. - -## Rendering and authoritative guidance - -Formatting is authority-aware: - -- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands; -- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay; -- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and -- current messages: unchanged. - -Update `skill-guides/orchestration.md` and regenerate its bundled source. The -guide explains automatic adoption, exact continuity, the retired scheduler -boundary, proof failures, current retries, and the Windows ask resume fallback. -It must not tell agents to restart a worker whose Dispatch was adopted. - -Formatting cannot use database provenance alone; current liveness and -attestation are runtime facts. Recovery may reveal the exact already-live PTY -once as an inactive background tab. It never creates a PTY or process, writes -input, switches workspaces, focuses, splits, or injects a terminal. - -## Deterministic verification - -### Schema and authority - -- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh - fixtures; preserve IDs and row counts and reuse one adopted Run. -- Assert Task, Dispatch, decision-gate, message, question-thread, and retained - Delivery Run ownership agrees; outstanding legacy Deliveries are fenced. -- Prove current retries in the adopted Run remain current across repeated - migrations. -- Test unique worker/coordinator import from prior hook commitment plus current - live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process, - duplicate-token, duplicate-role, equivalent-pane, and remote-attachment - ambiguity. -- Race a current-runtime hook before adoption and prove the immutable startup - commitment is neither overwritten nor confused with live proof. -- Cover manual coordinators with no `coordinator_runs` row and reject - conflicting candidates from Task, Dispatch, and message provenance. -- Assert all legacy authority failures occur before message/receipt/gate/state - effects while current invalid-capability rejection remains unchanged. -- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration - storage. - -### Exact old behavior and replay - -- Replay the pinned prompt's heartbeat, successful and `Failed:` - `worker_done`, task-only escalation, ask, and consuming check. -- Replay the old formatter's reply command separately. -- Cover every operation once after B and once accepted by A immediately before - the cutover where a durable semantic identity exists, including the bounded - recovery replay for an A check and preservation of A's persisted settlement. -- Prove one B invocation reuses its invocation ID across reconnect, while - separate identical heartbeat/escalation invocations remain distinct. -- Inject real transport cuts after check read, ask commit, reply commit, and - completion commit; prove stable IDs and one receipt/effect. -- Page a recovery cohort larger than one response limit across repeated - disconnects and prove every eligible row appears until ack, then never again. -- Test mixed legacy/current attempts for every send/check/ask/reply route and - prove current Delivery cannot consume legacy direct mail and old coordinator - check only advertises the exact non-consuming current Delivery command. - -### Live two-launch E2E - -The focused renderer/daemon restart regression reconstructs the rc.3-era -orphan artifact in a shared candidate profile: a durable legacy Dispatch, -sleeping provider record, and live daemon PTY with its visual binding removed. -It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf, -Task, and Dispatch; emits no provider-resume spawn or interruption; preserves -coordinator focus; and remains inert after switching away and back. This -candidate/candidate fixture covers the renderer regression deterministically -but does not replace the distinct installed-A/candidate-B packaged cutover -journey below. It does not claim packaged Windows updater behavior. - -Use distinct installed A and B artifacts plus an external append-only fake -agent spawn ledger: - -1. launch A with coordinator and worker terminals and record the replaceable A - desktop/main PID, runtime ID, version, and artifact hash separately from the - durable daemon, coordinator, and worker identities; -2. accept one command with durable semantic identity on A—pending ask, reply, - completion, or check—cut its response, close A without stopping the daemon - or fake agent, install B, and prove B owns the relaunched runtime; -3. assert A's desktop/main process and runtime are gone, B has a different - desktop/main PID, runtime ID, version, and artifact hash, while the daemon - plus coordinator/worker PID and start time, PTY/session, terminal handle, - tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical; -4. prove that exact pending ask, reply, completion, or check resumes or - deduplicates on B; do not use heartbeat, escalation, or an already-answered - A ask as an exactly-once oracle; -5. run the remaining exact old lifecycle commands and settle the same - Dispatch; -6. switch away and back and assert no second spawn/tab and no - `Conversation interrupted`; and -7. create a current attempt on preserved state and prove current grammar. - -The append-only spawn ledger and an append-only interruption ledger, daemon -inventory, runtime terminal inventory, and every viewer DOM independently -prove one launch, process, handle, tab, and no `Conversation interrupted`. -Transport fault injection cuts the real CLI connection after check output but -before ack, and after ask, reply, and completion commit. Re-run the byte-exact A -argv under B and assert stable IDs plus exactly one message, gate, reply, -settlement, and receipt. - -Retain #11142's local visible-inactive test and extend its oracle beyond DOM -counting with the ledgers and host inventory. - -Run separate remote journeys: - -- headed paired server A-to-B with a surviving external client; -- headless `orca serve` A-to-B with the same surviving-client oracle; -- both prove the remote PID/PTy continuity, both viewer states, zero - client-local handles/tabs, and wrong-host evidence with zero effects; -- Docker SSH restart/reconnect with a remote proof file binding the process, - pane, and token commitment; -- physical Windows WSL coverage proving the correct distro and rejecting a - wrong-distro token; and -- a git-independent folder workspace parameter through the same adoption - journey. - -Packaged Windows updater continuity is outside this migration gate. A separate -updater change must prove candidate-built A/B install, executable release, -resume, and normal uninstall behavior on Windows before making that guarantee. - -## Implementation slices - -1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox. -2. Hook-commitment import, live principal verifier, host scopes, and redaction. -3. Atomic compatibility receipts plus lifecycle send/escalation. -4. Check read/ack and cutover recovery replay. -5. Ask create/resume, reply conflict semantics, and coordinator binding. -6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback. -7. Authority-aware formatting and the version-matched orchestration guide. -8. Two-launch, remote, platform, and #11142 regression coverage. -9. Focused unit/type/lint/E2E checks and final adversarial review. - -## Definition of done - -- Updates are never blocked. -- Every durable legacy Task is owned by an ordinary visible Run. -- Every provably original live legacy Dispatch can finish through its existing - prompt without changing process or worktree. -- New attempts use the current contract and mailbox in that same Run. -- No authority comes from IDs or handles alone. -- No adoption step starts, stops, writes, signals, focuses, splits, or injects - a terminal. Exact live-PTY recovery may restore one inactive background - renderer tab/leaf binding. -- B-era disconnects replay or deduplicate instead of losing or duplicating - work; A-era repeatable signals and already-answered asks follow the explicit - ambiguity recovery rules. -- Lost scheduler memory is reported precisely without stranding durable state. -- Local/background, remote, SSH, WSL, folder, and Windows guarantees have - deterministic proof. -- The PR contains this plan, focused implementation, and no unrelated changes. diff --git a/docs/reference/remote-agent-session-host-authority.md b/docs/reference/remote-agent-session-host-authority.md deleted file mode 100644 index 80a905abef7..00000000000 --- a/docs/reference/remote-agent-session-host-authority.md +++ /dev/null @@ -1,456 +0,0 @@ -# Remote agent-session host authority - -Status: implemented single-PR v1 design for issues #8878 and #9352; deterministic validation complete. - -## Reliability contract - -- **Invariant (`agent-session.remote-host-authority`):** one provider-session identity has at most one live PTY owner and canonical host surface on every claim-capable route; exact exit retires that incarnation durably. -- **Failure source:** issues #8878 and #9352, including concurrent remote clients, ambiguous replies, exit-before-publication, and stale client snapshots. -- **Oracle:** the focused ownership/lifecycle matrix and `pnpm test:repro:remote-agent-session` prove one physical spawn, canonical retry adoption, exact exit retirement, stale-publication rejection, and no restart resurrection. -- **Gate:** the experimental `agent-session.remote-host-authority` entry in `config/reliability-gates.jsonc`. -- **Coverage:** deterministic macOS local/daemon/remote-runtime evidence plus SSH/relay fault-injection; Linux, Windows, WSL, and live SSH remain explicit gaps. -- **Performance budget:** no polling or terminal-output work; admission-only provider reconciliation is inflight-deduped, and operation state is capped and expiring. -- **Diagnostics:** structured RPC error codes, PTY incarnation IDs, owner generations, operation dispositions, and the repro artifact distinguish fallback, adoption, conflict, and retirement failures. -- **Residual gaps:** durable fresh-operation journaling, automatic sleep checkpoints, verified nested-SSH namespaces, and multi-process profile coordination are documented under Future extensions. - -## Summary - -A remote Orca host, not an attached renderer, decides whether a provider agent -session already has a live PTY. Clients send structured intent (fresh launch or -explicit provider identity); the host returns one canonical terminal surface. - -This fixes two related failures: - -- A paired client could consume its own persisted sleep record and launch a - second TUI while the remote host still owned the first one (#8878). -- An exited host terminal could remain in `session.tabs.list` as a handle-less - placeholder, get persisted by clients, and return as a ghost tab (#9352). - -The v1 protocol deliberately fails closed after authority side effects begin. -At mixed-version boundaries the host may return -`agent_session_legacy_required`, but only after a read-only execution-owner -check and before trust, claim, spawn, or any retained replay fence. The client can then run -its retained exact legacy request, so upgrading any subset of clients, hosts, -daemons, or relays does not remove workflows that worked before the upgrade. - -## Scope and guarantees - -This change guarantees: - -1. Runtime-owned worktrees always queue renderer sleep records into the normal - transport. A fully capable route turns that intent into an authoritative - ensure/adoption; mixed-version routes preserve the legacy wake behavior. -2. On capable hosts, known provider sessions resume through a structured - `terminal.ensureAgentSession` request. -3. On claim-capable execution routes, concurrent or repeated ensures for the - same canonical provider identity return one execution owner and one - canonical terminal surface. -4. A fresh launch uses `terminal.createAgentSession` with a caller-scoped - operation ID. Stable runtime surface identity, daemon session identity, and - relay operation identity prevent response-loss retries from creating a - second process while their respective owner remains alive. -5. New hosts continue accepting agent-bearing legacy terminal-create RPCs, so - old clients behave exactly as they did before the host upgrade. -6. Structured explicit resume returns `agent_session_legacy_required` before - side effects when a daemon is old or an SSH route cannot attest its execution - namespace. A claim-capable route still fails closed on malformed, - conflicting, or unknown ownership after dispatch. -7. Natural or explicit PTY exit retires only the exact PTY incarnation and - terminal surface from both `terminal.list` and `session.tabs.list`, repairs - active/group topology, and removes durable host persistence. -8. New clients use structured authority only when advertised. Capability - absence or a transient read-only probe failure selects the exact legacy - payload; protocol incompatibility remains blocked. - -The following are not v1 guarantees: - -- automatic resume of an intentionally sleeping remote agent; -- fresh-launch exactly-once behavior across a full runtime process restart; -- host-authoritative deduplication of resumes through an unverified - direct/nested SSH execution namespace (those launches retain legacy behavior); -- supervising a provider process after its owning PTY exits; -- coordinating multiple independent Orca main processes for one profile; -- preventing a nonconforming new client from deliberately sending the same - legacy wire request as an old client; authenticated request-level capability - negotiation does not yet exist, so the server cannot distinguish them. - -Those constraints are explicit so future work can extend the protocol without -weakening the v1 safety boundary. - -## Authority model - -There are three layers: - -| Layer | Responsibility | -| --------------------------------- | --------------------------------------------------------------------------------------- | -| Client/renderer | Sends structured intent and mirrors host snapshots | -| Runtime/controller | Resolves worktree and provider identity, signs a claim, publishes the canonical surface | -| Execution owner (daemon or relay) | Atomically claim-or-spawn, prove liveness, and recover live claims from listings | - -The execution owner is the lowest process that can atomically answer “is there -already a live PTY for this agent identity?” Keeping the registry there closes -the race between multiple runtime calls. The controller also keeps a registry -above providers so separate local/SSH routes cannot independently claim the -same identity. - -## Structured requests - -### Explicit resume - -`terminal.ensureAgentSession` accepts only a supported agent and normalized -provider identity: - -```ts -{ - kind: 'explicit' - worktree: string - agent: ResumableTuiAgent - providerSession: { - key: 'session_id' | 'conversation_id' - id: string - transcriptPath?: string - } - agentArgs?: string | null - launchPreferences?: { model?: string; effort?: string; mode?: string } - presentation?: 'focused' | 'background' - placement?: { tabId?: string; leafId?: string } -} -``` - -The host canonicalizes the provider identity, binds it to the execution -namespace and canonical worktree, and signs a digest claim. Raw resume commands -do not cross a claim-capable boundary; compatibility-selected legacy and -unverified nested SSH paths retain their prior opaque command behavior. - -The execution owner performs one atomic operation: - -```text -claim absent -> reserve -> spawn -> publish live owner -> created -claim live -> prove PTY liveness -> return canonical owner -> adopted -claim unknown -> fail closed; do not spawn -claim conflict -> fail closed; do not spawn -``` - -Only an adopted owner may override the requested tab, leaf, handle, or PTY ID. -A fresh provider result must match the surface requested by the host. - -### Fresh launch - -`terminal.createAgentSession` accepts structured agent, prompt-delivery mode, -launch preferences, optional explicit agent arguments, and a cryptographically -random client operation ID. Draft prompts remain drafts; submitted prompts use -the normal startup-delivery path. Omitted agent arguments preserve host -defaults, while an explicit string is preserved as a client override and an -explicit null/empty value clears host argument defaults. Free-form client -environment variables are deliberately not accepted because PATH, loaders, and -other process authority remain host-owned. - -The runtime reserves the caller-scoped operation before any asynchronous -workspace or capability preflight, then fingerprints the host-resolved request -under the authenticated device identity: - -- same caller + operation ID + same fingerprint returns `replayed`; -- same caller + operation ID + different fingerprint fails; -- malformed, future-dated, expired, or over-capacity operations fail closed. - -The operation ledger is memory-bound and retained for 24 hours. Pre-spawn -failures release the entry for a safe retry. Once PTY creation commits, or the -provider reports an unknown physical outcome, the same rejected promise remains -as the replay fence. This handles response loss and ordinary reconnects to the -same running host. It does not claim exactly-once creation after the runtime -process itself restarts. - -Physical commit is the native-spawn boundary, not listener registration or -surface publication. The in-process provider reports it immediately after -`node-pty` returns; daemon and relay paths report it when their lower owner -returns from spawn/create-or-attach. Commit reporting is one-shot across these -layers. Any later error retains the operation fence because the PTY may already -exist even if publication failed. - -The runtime derives the execution-operation ID, tab ID, leaf ID, and terminal -handle deterministically from the authenticated operation. A daemon-backed -spawn derives a legacy-length session ID from that execution operation, so -`createOrAttach` returns the same PTY after a lost response without shrinking -the accepted worktree-ID boundary or skipping first-spawn setup. An SSH -provider performs a bounded read-only relay probe before structured work: - -- a relay advertising `agentSessionCreateOperationVersion: 1` receives the - operation ID and replays one successful spawn result for 24 hours; -- an older, malformed, or temporarily unreachable relay makes the host return - `agent_session_legacy_required`, after which the client sends its unchanged - legacy payload; -- negative relay capability results are not pinned, so an in-place upgrade is - observed on the next request. - -Relay operation-owned PTYs survive stale request contexts so the retry can -recover the same PTY and incarnation. Ordinary stale shell spawns keep the -existing cleanup behavior. - -## Claim identity - -The claim contains no raw provider session ID. A host-only signer hashes: - -- normalized agent/provider identity; -- canonical worktree scope; -- execution machine and principal; -- container/runtime namespace; -- a conservative provider-root bucket. The v1 implementation deliberately - merges account roots for an agent, which can produce a safe conflict but - cannot authorize duplicate execution. - -The wire binding includes a key ID, digest version, identity digest, worktree -scope digest, and agent kind. Owner state adds a random generation, PTY ID, and -canonical surface. - -Generation and PTY-incarnation guards prevent a late exit or liveness result -from releasing, retiring, or adopting a replacement owner that reused the same -PTY ID. - -## Recovery and failure semantics - -Before every claimed ensure, the controller stages complete listings from local -and registered SSH providers, validates every owner, and atomically replaces -the authoritative portion of its registry. Absent owners are pruned only for -providers whose listing is authoritative; disconnected scopes retain their -fence. Active reservations survive reconciliation. Valid metadata also rebuilds -PTY-to-provider routing. - -Controller-owned in-process fallback claims are intentionally not serialized -in ordinary local process listings. Their listing absence is therefore not -authoritative: the controller keeps the claim while the exact PTY incarnation -remains listed and releases it through the normal exit path. Daemon routers and -degraded providers likewise advertise listing authority only for a proven -PTY-to-provider route; an unknown ID never falls through to an unrelated -current/fallback provider for this decision. - -Recovery is fail closed: - -- owner PTY differs from the listed session: `agent_session_ownership_unknown`; -- two listings disagree about an identity or generation: - `agent_session_conflict`; -- the recorded provider is disconnected or unregistered: - `execution_owner_unavailable`; -- a claim-bearing spawn reaches a daemon/relay without execution-owner claim - protocol v2 (including PTY incarnation proof): - `agent_session_claim_unavailable`. Nested SSH routes that cannot construct a - claim are selected into legacy behavior before this boundary. - -Unknown liveness never means dead. A transient relay outage therefore retains -the claim and cannot authorize a replacement agent. - -Daemon adoption is attach-only. If the owner exits between liveness proof and -attach, the request fails rather than falling through to a new unclaimed shell. - -Serialized relay shell state intentionally omits provider claims. Spawn-based -revival creates a new shell and cannot inherit authority from the old process. - -## Renderer behavior - -Runtime-owned worktrees queue cached resume evidence through the same mounted -pane transport regardless of capability-cache timing. A capable host adopts or -creates one canonical owner; an old host or execution owner receives the exact -legacy launch. A cold or expired cache therefore cannot bypass authority or -remove the pre-change workflow. - -AI Vault resumes use provider metadata for agents with a structured identity, -including Antigravity conversation IDs and Pi transcript/session paths. If -metadata or host capability is absent, Orca preserves the prior opaque legacy -resume request instead of blocking the user. - -Background launches, quick launches, and mounted remote panes use -`terminal.createAgentSession` on capable hosts. Otherwise each call site sends -the exact pre-change `terminal.create` or `session.tabs.createTerminal` payload. -The common router calls legacy after only these safe outcomes: the host capability -is unavailable before dispatch; the structured RPC returns the stable -pre-side-effect `agent_session_legacy_required` code; or a replaced old host -returns `method_not_found`, proving it never recognized the structured request. -Timeouts, malformed results, and every other structured error never downgrade. - -Structured create/ensure responses record an exact -environment/worktree/provisional-tab to canonical-host-tab handoff. Snapshot -reconciliation removes a provisional pane only when its requested tab ID is -mirrored or that explicit handoff points to a host tab in the snapshot; agent -kind alone is never identity. This prevents an unrelated Claude/Codex session -from deleting a same-agent automatic-resume pane. The matched pane's pending -startup and automatic-resume claim are removed atomically, and the client -re-accepts the current host snapshot in case it arrived before the response. - -Every structured result is host-owned. If snapshot handoff destroys the -provisional transport while create/ensure is in flight, late completion cannot -close the canonical PTY even when reconciliation is still catching up. - -After the host accepts creation, a later tab-move or snapshot-refresh failure -still returns `created`. Reporting the launch as failed would invite a retry -with a new operation ID and could duplicate the fresh agent. - -## Exit and persistence lifecycle - -PTY exit is terminal authority. A generic persisted `sleeping` row is not a -reason to preserve a surface. Only an exact, runtime-owned stop transaction may -temporarily preserve the intentional handle-less surface. Otherwise the -runtime: - -1. verifies the PTY incarnation and identifies the exact worktree/tab/leaf; -2. removes that leaf from the host snapshot; -3. removes an empty parent tab; -4. repairs split groups, active group/tab, recent order, and layout; -5. removes the terminal binding from the persisted host workspace session and - advances that repo's host topology revision; -6. synchronously flushes that retirement before publishing the in-memory - absence; -7. rebases later renderer writes onto the host's current terminal membership, - so metadata and layout edits remain writable but missing/live panes cannot - be added or removed by a stale client. - -The durable fence is one monotonic revision per affected repo, not one record -per historical close or deleted worktree. Its storage is therefore proportional -to repos plus current terminal surfaces. A real host-admitted spawn advances -the revision when it adds a tab or leaf, allowing fresh terminals after a -retirement while an older renderer snapshot remains unable to revive the old -surface. Legacy per-pane tombstones are accepted for mixed-version recovery -and collapsed into the repo revision on the next normalized write. -The revision remains private to each execution host: renderer hydration and -writes omit it, avoiding collisions when different hosts contain the same repo -ID. Each host preserves the revision while rebasing client session writes. - -An exit can also beat initial terminal registration. The runtime records that -PTY/incarnation before any surface exists, rejects registration of the same or -unproven incarnation before mutating provider/output sequence, execution -context, ownership, lease, binding, handle, terminal, or tab state. The native -callback or successful lower-owner return still reports physical-spawn commit -before this admission check so a lost provider response cannot authorize a -second fresh agent. A proven different incarnation, or an explicit new local -lifecycle for a provider that cannot report incarnation identity, clears the -fence. When registration rejects the recorded incarnation, that specific -caller's fence is released after rejection so repeated early-exit failures do -not accumulate process memory. - -Registration intent is explicit: the controller marks the expected PTY before -dispatch and clears that intent on every success or failure path. Surface -absence is never treated as evidence that registration is still in flight. -SSH and daemon providers also settle an attach/create response against any exit -that arrived in the same transport batch before returning control upward. This -keeps response/exit ordering and incarnation comparison at the layer that can -observe both events. - -“Explicitly killed” is treated as a normal terminal-gone lifecycle outcome in -the remote transport, not as an unexpected product-error toast. - -## Protocol and compatibility - -The runtime protocol remains v3, with minimum compatible client and server v2. -This change adds optional RPCs and fields, so a protocol fence would make a -rolling upgrade worse without providing an authorization boundary. The runtime -advertises `agent-session.host-authority.v1`; clients negotiate that capability -before choosing a launch path. - -| Client | Host | Result | -| ------ | ---- | -------------------------------------------------------------------------------------------------------- | -| New | New | Structured authority is enabled when the resolved execution owner also supports it. | -| New | Old | The client selects legacy before spawn, or falls back on safe `method_not_found`; behavior is unchanged. | -| Old | New | The host still accepts legacy agent-bearing terminal creates; behavior is unchanged. | -| Old | Old | Unchanged legacy behavior. | - -Capability probing is read-only. A transient probe failure may select legacy, -because no structured side effect has started. A real protocol compatibility -block is still surfaced and never bypassed. A capable host then checks the -resolved daemon or relay. Only `agent_session_legacy_required`, emitted before -trust, claim, spawn, or any retained replay fence, permits legacy; every later error stays on -the structured path. The other post-dispatch exception is `method_not_found` -from an old host, which proves the method could not have started. Host and -lower-owner unsupported verdicts are not pinned, and observing a new runtime ID -invalidates a predecessor's positive verdict, so rolling upgrades and process -replacement re-probe promptly. The SSH probe is bounded below the client RPC -timeout, concurrent callers have independent cancellation, successful -structured creates require PTY and incarnation identity, and request -cancellation is checked at the real provider seams: after asynchronous -capability/connection preflight and immediately before local native spawn, -daemon `createOrAttach`, or SSH `pty.spawn`. Once SSH dispatch begins, an -operation failure is treated as an unknown physical outcome rather than a safe -fresh retry. - -The same monotonic rule applies below the runtime: - -| Runtime/controller | Execution owner | Result | -| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------- | -| New | New daemon | Stable operation-derived session ID makes retry attach to the same PTY. | -| New | Old daemon | The host returns `legacy_required` before side effects; the client sends its exact old resume/create request. | -| New | New relay | Relay operation ledger replays the same PTY and incarnation. | -| New | Old relay | The host returns `legacy_required` before side effects; the client sends its exact old spawn request. | -| Old | New daemon/relay | New optional fields are absent, so pre-change behavior is unchanged. | - -This contract is monotonic: upgrading any subset never removes a workflow that -worked before. The bug fix activates only where every authority layer required -for that specific path can prove support. - -## Deterministic reproduction harness - -Run: - -```sh -pnpm test:repro:remote-agent-session -``` - -The harness builds Orca, starts a real headless Electron `orca serve` process on -an ephemeral port, and connects independent Node client processes over the -normal encrypted WebSocket pairing path. It creates and registers a real Git -repository in an isolated profile and uses the real daemon claim registry with -a controlled agent subprocess. No installed agent, external service, fixed -port, timing race, or Docker daemon is needed. - -It asserts: - -- two clients race the same structured resume; -- exactly one daemon subprocess is spawned; -- both clients receive the same canonical handle, tab, pane, and PTY; -- a retry that may have lost its earlier response adopts that owner; -- a real `terminal.close` produces PTY exit and both `terminal.list` and - `session.tabs.list` omit the surface; -- a stale layout publication cannot recreate the retired surface; -- restarting the serve process with the same profile cannot resurrect the - terminal or tab. - -Lower-level tests separately cover daemon attach races, controller recovery, -provider disconnects, conflicting listings, old SSH relays, malformed SSH -claim results, cancellation at physical provider seams, pre-publication native -spawn failures, exact provisional handoff, early exit before registration, and -exit-driven durable retirement. - -## Future extensions - -### Host-owned automatic sleep checkpoints - -Automatic remote sleep/resume should be added only as a host transaction: - -1. persist a random, generation-bound checkpoint before stopping; -2. publish a non-connectable transition state; -3. stop and verify the exact owner; -4. commit sleeping state only after the owner is gone; -5. consume the checkpoint atomically during ensure. - -Until this exists, renderer-local records may trigger resume intent but cannot -authorize a second owner on a claim-capable route. Compatibility-selected -legacy mode keeps the pre-change behavior. - -### Durable fresh-operation journal - -If fresh-launch exactly-once behavior must survive runtime restart, replace the -memory ledger with a profile-scoped durable journal. It must persist the caller, -operation ID, request fingerprint, canonical result/tombstone, and retention -deadline before returning success. Capacity must reject rather than evict an -unexpired tombstone. - -### Verified SSH execution namespaces - -Direct or nested SSH agent-session authority requires relay-attested machine, -principal, container, and provider-root identity plus a separately versioned -claim capability. Connection labels or target aliases are not proof. Until -that attestation exists, the host requests exact legacy fallback before spawn; -v1 does not claim deduplication for that route. - -### Multi-process coordination - -Supporting multiple Orca main processes against one profile requires an -OS-held coordinator lease around claim and journal mutation. The current v1 -contract coordinates clients of one runtime/controller process and its daemon -or registered relays. diff --git a/docs/reference/resource-manager-memory-metrics.md b/docs/reference/resource-manager-memory-metrics.md deleted file mode 100644 index c8c6627bebb..00000000000 --- a/docs/reference/resource-manager-memory-metrics.md +++ /dev/null @@ -1,54 +0,0 @@ -# Resource Manager memory metrics - -Resource Manager reports two different kinds of memory data. Process totals describe Orca and -locally managed terminal processes; host totals describe the machine running Orca. They are not -interchangeable. - -## Process memory - -Each snapshot declares one `processMemoryMetric`: - -| Platform | Metric | Source | -| ------------ | ------------- | ---------------------------------------------------------- | -| macOS, Linux | `rss` | `ps` resident set size | -| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback | - -Orca walks each registered local PTY subtree and claims every PID at most once. App, session, -worktree, history, and snapshot memory values are sums of those per-process samples. - -RSS and working set are not unique physical-memory measurements. Shared pages can appear in more -than one process, and macOS can count the same resident page through multiple mappings in one -process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the -metric as a sum and must not present it as a percentage of system RAM. - -Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until -the executing host supplies resource data. Folder workspaces use the same registered-PTY -attribution as Git worktrees. - -## Host memory - -`HostMemory` keeps immediate free memory separate from memory available without material pressure: - -- `freeMemory` is Node's host free-memory value. -- `availableMemory` is the best bounded platform value. -- `usedMemory` is `totalMemory - availableMemory`. -- `memoryUsagePercent` is derived from `usedMemory`. -- `availableMemorySource` identifies how availability was measured. - -The sources are: - -| Platform | Preferred source | Fallback | -| -------- | ----------------------------------------- | ---------------- | -| macOS | `memory_pressure -Q` available percentage | Node free memory | -| Linux | `/proc/meminfo` `MemAvailable` | Node free memory | -| Windows | Node free memory | Node free memory | - -This percentage describes host availability. It is not derived from the summed process metric. - -## Bounded collection - -Resource Manager polls while its popover is open, so every snapshot must remain bounded under a -large process tree. macOS `footprint` provides a more physical process metric, but collecting it -across dozens of processes is too expensive for this path. A future physical-footprint backend -must use a bounded host API or helper and declare a distinct metric before product copy treats it -as physical memory. diff --git a/docs/refresh-github-issues-after-create.md b/docs/refresh-github-issues-after-create.md deleted file mode 100644 index 94b2931be4e..00000000000 --- a/docs/refresh-github-issues-after-create.md +++ /dev/null @@ -1,138 +0,0 @@ -# Refresh GitHub Issues After Create - -## Problem - -Issue https://github.com/stablyai/orca-internal/issues/101 reports that the GitHub issues list can stay stale after creating a new issue. - -- `src/renderer/src/components/TaskPage.tsx:3891` bumps `taskRefreshNonce` after successful issue creation, intending to refetch the list. -- `src/renderer/src/store/slices/github.ts:1644` honors `force` only for the renderer cache and in-flight dedupe. -- `src/renderer/src/store/slices/github.ts:1666` calls `window.api.gh.listWorkItems` without telling main to bypass the GitHub CLI cache. -- `src/main/github/client.ts:821` and `src/main/github/client.ts:846` use `gh api --cache 120s` for the recent issues and PR REST paths, so a forced renderer refresh can still receive a pre-create response for up to two minutes. -- `src/main/runtime/rpc/methods/github.ts:10` and `src/main/runtime/rpc/methods/github.ts:283` do not accept or forward a cache-bypass flag for SSH/runtime clients. - -## Root Cause - -The post-create flow forces only Orca's renderer-side work-item cache. It does not bypass the GitHub CLI REST cache used by the main-process recent work-item fetch, so the refreshed request can reuse stale `gh api --cache 120s` data. - -## Non-Goals - -- Replace normal list caching or reduce default cache TTLs. -- Change query/search filtering behavior. -- Change GitLab, Linear, project view, or PR detail caches. -- Add polling after issue creation. -- Add new UI controls or visible copy. - -## Design - -1. Add an optional `noCache` flag to the renderer work-item fetch options and the GitHub work-items list contract: - - `FetchOptions` in `src/renderer/src/store/slices/github.ts`; - - preload API type and implementation for `gh.listWorkItems`; - - IPC handler args for `gh:listWorkItems`; - - web preload routing, which forwards `gh.listWorkItems` to `github.listWorkItems` for web/remote clients; - - runtime RPC schema and handler for `github.listWorkItems`; - - `OrcaRuntime.listRepoWorkItems`; - - `listWorkItems` and the internal recent-list helper in `src/main/github/client.ts`. - -2. Keep `force` and `noCache` separate. `force` means "bypass renderer cache and in-flight dedupe"; `noCache` means "bypass `gh api --cache`". In TaskPage, pass `{ force: forcedFetch || shouldProbeOnLanding, noCache: forcedFetch }` so nonce-triggered refreshes and preference invalidation bypass the GitHub CLI cache, while the one-time landing probe still behaves like today's background revalidation. Today `taskRefreshNonce` is shared by create, manual refresh, retry, filtering, preset changes, and PR merge refresh, so the implementation should either accept that whole nonce-triggered set as the no-cache scope or split create/manual refresh intent into a separate signal before narrowing it. - -3. When `fetchWorkItems(..., { noCache: true })` calls `window.api.gh.listWorkItems`, pass `noCache: true`; otherwise omit it or pass `false`. - -4. Track `noCache` alongside `force` in `inflightWorkItemsRequests`. A request with `noCache: true` must not dedupe onto an existing request with `noCache: false`, even if that existing request is forced; wait for the existing request to settle and issue a fresh no-cache request. This preserves the create path when it races a landing probe, which is `force: true` but intentionally cacheable. - -5. In `listRecentWorkItems`, build REST args with `[]` when `noCache` is true and `['--cache', '120s']` otherwise. Apply this only to the REST `gh api` issue/PR list calls that currently use the cache. Keep fallback `gh issue list` / `gh pr list` and queried paths unchanged because they do not use this REST cache. - -6. Preserve current force semantics: - - non-forced loads keep using the 120-second CLI cache; - - forced loads still wait out non-forced in-flight requests before issuing a fresh request; - - no-cache loads also wait out cacheable in-flight requests before issuing a fresh request; - - force continues to refresh all selected repos through the existing TaskPage effect. - -7. Add regression coverage: - - renderer store: `force + noCache` sends `noCache: true`; `force` without `noCache` and non-force calls omit it; - - renderer store: `force + noCache` does not dedupe onto an in-flight `force` request that lacks `noCache`; - - TaskPage or a focused equivalent: post-create/manual nonce path sets `noCache`, but landing probe does not; - - desktop IPC: `gh:listWorkItems` forwards `noCache`; - - web preload: `gh.listWorkItems` forwards `noCache` through the runtime route; - - main GitHub client: `listWorkItems(..., { noCache: true })` omits `--cache 120s` on recent REST issue/PR calls; - - runtime RPC: `github.listWorkItems` accepts and forwards `noCache`. - -## Data Flow - -- User creates GitHub issue in Tasks. -- `handleCreateNewIssue` bumps `taskRefreshNonce`. -- TaskPage effect computes `forcedFetch=true`. -- `fetchWorkItemsAcrossRepos` calls `fetchWorkItems` with `{ force: true, noCache: true }` for the nonce-triggered refresh. -- `fetchWorkItems` bypasses renderer cache and, when `noCache` is set, calls `gh.listWorkItems({ noCache: true })`. -- Desktop IPC or runtime RPC forwards `noCache`. -- `listRecentWorkItems` omits `gh api --cache 120s` for that fetch. -- GitHub returns a fresh recent issue list; cache is repopulated with the new issue. - -## Edge Cases - -- Multiple selected repos: all selected repos refresh through the existing fan-out; `noCache` applies only to `forcedFetch`, not the landing probe. -- Fork/upstream issue source: source resolution stays unchanged, and the cache bypass applies to whichever source is selected. -- SSH/runtime repo: runtime RPC accepts and forwards `noCache`, so remote clients do not retain the stale-cache bug. -- In-flight non-forced request: existing force logic waits for the stale request to settle, then issues a fresh no-cache request. -- In-flight forced landing probe: a nonce-triggered no-cache request must not dedupe onto the cacheable landing probe; otherwise create can still repaint from `gh api --cache 120s`. -- One-time landing probe: it still uses `force` to bypass renderer freshness, but must not set `noCache`; otherwise merely opening Tasks with cached rows would spend uncached GitHub API requests. -- Search query active: queried paths already use `gh issue list` / `gh pr list` rather than cached REST calls, so no behavior change is required. -- Pagination: next-page fetches use queried/cursor paths and do not populate the renderer work-items cache, so `noCache` is page-0-only. -- Concurrent windows: the creating window refreshes immediately; other renderer windows keep their own cache until their next refresh, landing probe, or TTL expiry. This change should not introduce cross-window invalidation. -- External GitHub mutations: external issue changes still rely on existing TTL/manual refresh behavior; this fix only guarantees freshness for Orca-originated create flows. -- Network/auth errors: existing partial-failure handling and banners remain unchanged. - -## Test Plan - -- Unit: extend `src/renderer/src/store/slices/github.test.ts` or add focused coverage for `fetchWorkItems` `force`/`noCache` IPC args and the no-cache-vs-cacheable-in-flight dedupe case. -- Unit: cover the TaskPage nonce path or isolate the option computation so nonce-triggered refreshes set `noCache` and the landing probe does not. If implementation narrows no-cache to a new create/manual-refresh signal, cover that narrower signal explicitly. -- Unit: extend `src/main/ipc/github.test.ts` to assert `gh:listWorkItems` forwards `noCache` to the client. -- Unit: extend `src/renderer/src/web/web-preload-api.test.ts` to assert web/remote `gh.listWorkItems` preserves `noCache`. -- Unit: extend `src/main/github/client-issue-source.test.ts` or `src/main/github/client-work-items.test.ts` to assert recent no-cache requests omit `--cache 120s` while normal recent requests keep it. -- Unit: extend `src/main/runtime/rpc/methods/github.test.ts` and/or `src/main/runtime/orca-runtime.test.ts` for `noCache` schema/forwarding. -- Typecheck: `pnpm typecheck`. -- Lint: `pnpm lint`. -- Electron validation: create an issue only in a throwaway/test repo if available; otherwise validate the refresh behavior with mocked/local unit tests and capture the Tasks issue list state without mutating live data. - -## UI Quality Bar - -Not UI-visible. The existing issue list UI and create-issue dialog should look unchanged; only freshness after a forced refresh changes. - -## Review Screenshots - -1. GitHub Tasks issue list after refresh/create path is reachable. -2. Create issue dialog before submission, if validation can use a throwaway repo. -3. Post-create issue detail/list state, only if validation can use a throwaway repo without mutating live user data. - -## Rollout - -1. Add `noCache` to renderer fetch options plus shared/preload/web/runtime IPC contracts. -2. Thread `noCache` through web routing, runtime, and desktop main-process handlers. -3. Apply `noCache` to the recent REST work-item list args. -4. Pass `noCache` from nonce-triggered renderer work-item fetches, but not landing probes. -5. Add regression tests. -6. Run typecheck, lint, and focused tests. - -## Lightweight Eng Review - -- Scope: reduced to force-refresh cache bypass for the existing work-item list path; no polling, UI changes, or TTL changes. -- Architecture/data flow: the renderer already owns refresh intent, while main owns GitHub CLI args. Thread `noCache` as explicit request metadata across preload, web preload, desktop IPC, runtime RPC, and SSH-aware runtime methods; do not infer it from every `force` call because landing probes also use `force`. -- Failure modes covered: - - stale `gh api --cache 120s` response after create; - - forced fetch deduping onto a non-forced in-flight request; - - runtime/SSH clients lacking the cache-bypass argument; - - upstream/origin issue-source selection still resolving before fetch; - - queried path accidentally changing despite not using REST cache. -- Test coverage required: - - renderer store IPC args for `force`/`noCache` combinations; - - TaskPage option computation for nonce-triggered refresh vs landing probe; - - desktop IPC and web preload forwarding; - - main GitHub client recent-list REST args with and without `noCache`; - - runtime RPC schema/handler forwarding `noCache`; - - focused typecheck/lint. -- Performance/blast radius: low when `noCache` is limited to nonce-triggered refreshes and preference invalidation. Normal loads and landing probes keep the CLI cache; the no-cache path doubles the fresh REST calls for repos that have both issue and PR sources, so avoid broadening it to every renderer `force`. -- UI quality bar: not UI-visible; UI should remain unchanged apart from fresher rows. -- Required review screenshots: - 1. Tasks GitHub issues list reachable after implementation. - 2. Create issue dialog reachable, if a throwaway repo is available. - 3. Post-create or post-refresh list state, if validation can avoid mutating live data. -- Residual risks: validating the actual create flow may be skipped unless a safe throwaway GitHub repo is available; cross-window freshness and external GitHub mutations remain bounded by existing refresh/TTL behavior. diff --git a/docs/remote-web-paste-activity-fixes.md b/docs/remote-web-paste-activity-fixes.md deleted file mode 100644 index dca8ee5bed6..00000000000 --- a/docs/remote-web-paste-activity-fixes.md +++ /dev/null @@ -1,108 +0,0 @@ -# Remote Web Paste And Activity Fixes - -## Problem - -- Paired browser image paste reads a clipboard image in `src/renderer/src/web/web-preload-api.ts:120`, converts it to PNG base64, and sends the whole string through `clipboard.saveImageAsTempFile` at `src/renderer/src/web/web-preload-api.ts:1613`. `src/main/runtime/rpc/ws-transport.ts` sets `maxPayload` to 1 MiB, and the browser RPC client encrypts JSON requests into base64 text frames. A screenshot well below the existing 24 MiB clipboard schema limit can still exceed the encrypted WebSocket frame cap and close the socket as `Remote Orca runtime connection interrupted`. -- Paired browser workspace activity is inflated. `src/renderer/src/lib/worktree-status.ts` and `src/renderer/src/lib/worktree-activity-state.ts` currently treat any mirrored web terminal surface ID as active, even when `src/renderer/src/runtime/web-session-tabs-sync.ts` only has a pending host terminal with no ready PTY handle. - -## Root Cause - -- The clipboard RPC accepts up to 24 MiB of base64 in `src/main/runtime/rpc/methods/clipboard.ts`, but the web transport limit applies before the server can validate RPC params. Because the web client encrypts JSON and base64-encodes the encrypted bytes, the safe plaintext chunk is materially smaller than 1 MiB. The existing 512 KiB file-upload chunk size in `src/renderer/src/runtime/runtime-file-client.ts` is safe after JSON plus encryption expansion; a chunk close to 1 MiB is not. -- The file-upload RPCs cannot be reused directly. They write worktree-relative files and then commit a rename; clipboard paste must call `saveClipboardImageBufferAsTempFile`, return a local or SSH temp path, and preserve the terminal repo's `connectionId`. -- Web session-tab sync intentionally mirrors pending host terminal surfaces so the tab model stays in parity, but it writes `ptyIdsByTabId[mirroredTabId]` only from `status: "ready"` surfaces. The status/filter helpers then bypass that liveness map with `isWebTerminalSurfaceTabId`, so pending mirrors look active. - -## Non-Goals - -- Do not raise the global WebSocket `maxPayload`; it protects all runtime traffic, including pre-auth and mobile sockets. -- Do not change sidebar grouping, labels, counts, pairing flow, terminal UI, or clipboard permission UI. -- Do not change local Electron clipboard behavior or the existing one-shot RPC contract. - -## Design - -1. Add bounded chunked clipboard-image RPCs. - - Keep `clipboard.saveImageAsTempFile` for small/old clients. - - Add `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and `clipboard.abortImageUpload`. - - `start` takes `expectedBase64Length` and `connectionId`, rejects values over the existing 24 MiB base64 limit, records the target connection, and returns an unguessable upload ID. - - `append` takes `uploadId`, `offset`, and `contentBase64`; reject unknown IDs, out-of-order offsets, chunks above 512 KiB, invalid base64 characters, and cumulative length beyond `expectedBase64Length`. - - `commit` verifies the received length equals `expectedBase64Length`, validates the full base64 payload with the same rules as the one-shot RPC, calls `saveClipboardImageBufferAsTempFile` with the recorded `connectionId`, and deletes the upload state in `finally`. - - `abort` deletes the upload state and is idempotent. - - Bound server memory with a small max concurrent upload count and a TTL cleanup for abandoned uploads. Upload state is process-local; reconnects restart the paste instead of resuming. - -2. Switch paired browser image paste to the chunked path. - - After `navigator.clipboard.read()` and PNG conversion, return `null` for no image as today. - - Preflight `contentBase64.length` against the 24 MiB limit before starting an upload. - - Send 512 KiB base64 slices. This is below the 1 MiB encrypted frame cap after JSON and E2EE base64 expansion. - - Abort best-effort on append or commit failure. If `start` returns `method_not_found`, fall back to `clipboard.saveImageAsTempFile` only when the payload is below a conservative single-frame threshold; never send a large fallback frame. - -3. Tighten workspace activity liveness. - - Treat terminal workspaces as active only when `tabHasLivePty(ptyIdsByTabId, tab.id)` is true. - - Remove the blanket `isWebTerminalSurfaceTabId` active shortcut from `getWorktreeStatus` and `hasActiveWorkspaceActivity`. - - Keep browser tabs active without terminals. - - Keep fresh/retained explicit agent rows able to promote status to `permission`, `working`, or `done`; this is separate from terminal liveness. - -## Data Flow - -- Paste image: - - Browser paste command -> `navigator.clipboard.read()` -> PNG base64 in web preload memory. - - `clipboard.startImageUpload({ expectedBase64Length, connectionId })` -> upload ID. - - Repeated `clipboard.appendImageUploadChunk({ uploadId, offset, contentBase64 })`. - - `clipboard.commitImageUpload({ uploadId })` -> runtime saves temp image locally or on the SSH target -> terminal receives the temp path. - -- Workspace activity: - - Host `session.tabs.listAll` or subscription snapshot includes terminal surfaces. - - Web sync mirrors tabs but writes live PTY handles only for ready surfaces. - - Sidebar status/filter reads `ptyIdsByTabId` and browser tabs. - - Pending mirrored terminals with no PTY remain visible but do not count as active. - -## Edge Cases - -- Clipboard has no image or browser lacks `navigator.clipboard.read`: return `null`, no upload session. -- Clipboard read/permission/conversion fails: existing terminal paste error path reports the failure; the runtime socket should stay open. -- Non-PNG clipboard images may grow during PNG conversion; validate the post-conversion base64 length. -- Image exceeds 24 MiB base64: reject before upload and enforce again on the server. -- Chunk boundaries must preserve base64 validity; the 512 KiB chunk size is divisible by 4, and the final full payload validation catches padding errors. -- Append retry or concurrent append with the same upload ID: offset validation rejects duplicate, skipped, or out-of-order data. -- Multiple paired browser clients or windows paste at once: upload IDs isolate sessions and the concurrent-upload cap bounds memory. -- Append or commit fails: browser aborts best-effort; server TTL cleans abandoned state. -- SSH connection missing or drops during commit: commit fails, upload state is still deleted, and the paste path reports the error. -- Runtime restarts during upload: pending RPC fails; no resume is attempted. -- Pending host terminal surface later becomes ready: the next snapshot writes PTY handles and the workspace becomes active. -- Host closes a terminal or browser tab: the next snapshot removes the mirrored tab and stale PTY/browser handles before activity is recomputed. - -## Test Plan - -- Unit: `src/main/runtime/rpc/methods/clipboard.test.ts` for start/append/commit/abort, offset validation, invalid base64, size limit, TTL cleanup, commit cleanup on save failure, SSH `connectionId` forwarding, and concurrent upload isolation. -- Unit: `src/renderer/src/web/web-preload-api.test.ts` for chunk sequencing, 512 KiB max chunks, `method_not_found` small-payload fallback, no large fallback frame, and abort on append/commit failure. -- Unit: `src/renderer/src/lib/worktree-status.test.ts` and `src/renderer/src/lib/worktree-activity-state.test.ts` for pending mirrored terminal inactivity, ready mirrored terminal activity, browser-only activity, and explicit agent-row promotion. -- Focused run: `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/clipboard.test.ts src/renderer/src/web/web-preload-api.test.ts src/renderer/src/lib/worktree-status.test.ts src/renderer/src/lib/worktree-activity-state.test.ts`. -- Type/lint: `pnpm typecheck`, `pnpm lint`. -- Paired-client validation: restart the host app, pair a real browser client, paste a screenshot large enough that the old one-shot RPC exceeded the 1 MiB encrypted frame cap into a local terminal and an SSH-backed terminal, confirm the runtime socket stays connected, then compare browser and host sidebar activity. - -## UI Quality Bar - -No layout changes. The paired browser sidebar should show active dots only for workspaces with live terminal PTYs, browser tabs, or explicit agent rows. Image paste should insert the generated temp image path into the terminal without connection-error toasts or DevTools runtime disconnect errors. - -## Review Screenshots - -1. Paired browser sidebar after hydration with inactive Done/Todo workspaces visible but not marked active. -2. Paired browser terminal after image paste, showing the generated temp image path inserted. -3. Host sidebar for the same session, showing matching active workspace state. - -## Rollout - -1. Add chunked clipboard RPC implementation and tests. -2. Switch web preload image paste to chunked upload and tests. -3. Tighten activity/status liveness helpers and tests. -4. Run focused tests, typecheck, and lint. -5. Validate in a paired Electron/browser session, including SSH paste, and capture the review screenshots. - -## Lightweight Eng Review - -- Scope: narrow to web clipboard transport and activity liveness. No global WebSocket limit, sidebar redesign, or local Electron clipboard changes. -- Architecture/data flow: web preload owns browser clipboard read and chunk sequencing; main runtime clipboard RPC owns upload session state and final temp-file save; sidebar helpers stay pure and consume the existing live-PTY map. -- Failure modes: oversized images must fail before a large frame is sent; abandoned uploads expire; failed append/commit paths clean up; pending host terminal mirrors do not inflate activity; SSH commit failures do not leak upload state. -- Tests: cover RPC lifecycle and bounds, web preload sequencing/fallback/abort, status/filter liveness, and real paired-client paste/sidebar parity. -- Performance/blast radius: chunking is not free. A max-size image is dozens of serialized runtime RPC calls and duplicates base64 in browser and main memory. The impact is limited to image paste by TTL, size, and concurrency caps. Activity changes affect sidebar filters, jump palette activity, and status dots. -- UI quality: no new chrome. Judge only sidebar parity, paste result, and absence of runtime disconnect/toast regressions. -- Screenshots: browser sidebar, browser terminal after paste, and matching host sidebar. -- Residual risk: clipboard permission and image conversion behavior are browser-dependent, so paired browser validation is required in addition to unit tests. diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md deleted file mode 100644 index dbf3ee71dd5..00000000000 --- a/docs/renderer-memory-profile-2026-06-01.md +++ /dev/null @@ -1,171 +0,0 @@ -# Renderer Memory Profile, 2026-06-01 - -## Scope - -This profile investigated high Orca renderer memory while working in -`/Users/nwparker/orca/workspaces/orca/goal`. The user suspected the browser -might not actually have an open tab, so the investigation checked browser, -renderer, terminal, and Resource Usage attribution paths separately. - -## Live Evidence - -- Orca runtime was reachable through the packaged CLI fallback. The public - `/usr/local/bin/orca` shim pointed at a removed development app path, so it - failed before contacting the runtime. -- `orca tab list --worktree all --json` returned `tabs: []`. There were no live - Orca browser tabs in the measured session. -- The live packaged app had one renderer process and no separate browser guest - renderer process. A later `ps` sample showed: - - main process: 390 MB RSS - - renderer process: 460 MB RSS, about 40 percent CPU - - GPU process: 145 MB RSS - - network service: 59 MB RSS - - audio service: 48 MB RSS -- `sample` on the renderer showed V8, IPC, and deserialization stacks while the - renderer was busy. It did not show browser guest activity. -- `vmmap -summary` on the renderer showed about 218 MB physical footprint and a - 373 MB peak, while total resident accounting was about 1.7 GB. Most of that - larger number was shared Electron/Chromium mappings, especially read-only - library mappings. -- `orca terminal list --worktree active --json` showed the active Codex terminal - preview retaining repeated status redraw fragments such as repeated - `Working` text. The retained terminal tail buffers were bounded, but the - text normalization path was treating redraw controls as append-only text. - -## Findings - -1. Browser tabs were not the live-session memory source. The session had no - browser tabs and no browser guest renderer process. -2. The browser-pane retention fix is still useful: inactive worktree browser - webviews are now unmounted so Chromium can release guest renderers. Browser - state remains in Orca, and automation-visible webviews stay mounted so - agent-browser can keep driving them. -3. Resource Usage was using `app.getAppMetrics().memory.workingSetSize` for - Orca app buckets. On macOS this can count large shared Electron/Chromium - mappings and make the renderer look much larger than its private footprint. -4. The active terminal path was producing noisy previews from TUI redraws. This - explains the high active renderer churn observed during the profile, even - though the terminal memory buffers were already capped. - -## Changes Made - -- Browser panes now mount their backing webview only when the pane is active or - automation-visible. This sleeps inactive worktree browser guest renderers - without sleeping the main Orca renderer. -- Browser crash breadcrumbs now include webview counts, parked webview counts, - hidden webviews, and registered browser guest counts. -- The memory collector now prefers the existing host process RSS sweep for - Electron app bucket memory, falling back to Electron working-set data only - when a host row is missing. -- Terminal preview retention now applies carriage-return and backspace redraw - controls before appending text to the retained preview tail. - -## Validation - -- Browser overlay, webview registry, and crash diagnostics tests passed. -- Browser tab e2e tests passed. -- Memory collector tests passed, including host RSS preference and fallback - coverage. -- Runtime terminal tests passed for carriage-return and backspace redraw - normalization, plus the existing bounded partial-tail coverage. - -## Remaining Risk - -The current packaged Orca app was not running this worktree's patched code -during the live profile. The fixes are covered by unit and e2e tests, but the -next packaged build should be re-profiled under the same active Codex TUI load -to confirm the Resource Usage display and terminal previews match the expected -lower-churn behavior. - -## Follow-up: CLI Profiling Blocker - -Continuing the profile after this change confirmed the public -`/usr/local/bin/orca` command was still broken because it was a regular -generated launcher file pointing at a removed development build. The CLI -installer previously self-healed stale symlinks, but treated regular files as -conflicts. That meant Settings could not replace an Orca-owned stale launcher, -forcing profiling to use the packaged CLI fallback. - -The follow-up fix teaches the installer to recognize only generated Orca Unix -launcher files as stale and replaceable. Arbitrary regular files at the command -path remain conflicts. - -## Follow-up: Repeatable Memory Diagnostics - -The next profiling blocker was repeatability: collecting a useful memory sample -still required combining Resource Usage IPC, terminal lists, browser tab state, -and host process output by hand. This branch adds `orca diagnostics memory`, -which exposes the existing main-process memory collector through runtime RPC. - -The command returns the same `MemorySnapshot` shape used by Resource Usage when -run with `--json`, including host memory, Orca app process buckets, worktree -terminal memory, per-session process roots, and history samples. Text output -prints a compact point-in-time summary and the top worktrees by retained -terminal memory. - -## Follow-up: Agent-Browser Paintability Guard - -The browser parking fix depends on automation-visible panes staying paintable -without activating the user's worktree. The renderer bridge previously waited -for two animation frames before creating the automation visibility lease, so the -paint wait happened while the parked webview was still hidden. Non-screenshot -agent-browser commands could therefore start immediately after the lease was -created, before React had made the hidden pane paintable. - -The follow-up changes the order: create the automation visibility lease first, -then wait for paint while the pane is actually visible to automation. A -renderer-side timeout releases the lease if paint never arrives, so a hung RAF -does not pin an inactive browser pane indefinitely. - -## Follow-up: Browser Registration Readiness - -One remaining automation race was the wake path for parked or restored browser -tabs. Runtime browser commands asked the renderer to mount a hidden browser -pane, then waited a fixed 500 ms before reading the agent-browser tab registry. -On slow webview startup, that could still race `registerGuest` and make -agent-browser report no tab even though the tab was in the process of mounting. - -The follow-up extends the existing tab-registration wait from page-specific -creation to worktree/global wake flows. Runtime commands now wait for the -renderer's actual `browser:registerGuest` IPC before routing automation, with -the same timeout fallback used by tab creation. - -## Follow-up: Worktree Activation No-Op Fanout - -The next renderer-store check found that repeated activation of an already -active, already-reconciled worktree could still publish a new Zustand root state -because `setActiveWorktree` rebuilt `activeTabTypeByWorktree` even when its -stored value was unchanged. That woke every store subscriber, including session -persistence and runtime graph sync, for a visible no-op. - -The follow-up preserves the existing state reference when all derived active -fields, unread state, and first-activation bookkeeping are unchanged. A -regression test subscribes to the store and asserts that reselecting the -already-active reconciled worktree does not notify subscribers. - -## Follow-up: Activation Helper Visit Writes - -After the store-level no-op fix, the higher-level `activateAndRevealWorktree` -helper could still restamp focus-recency and append navigation history for a -plain reselect of the already-active worktree in terminal view. That path did -not change the visible workspace, but the recency stamp is part of the persisted -session payload and can still wake the session writer. - -The follow-up skips only that true no-op visit write. Activations that switch -repo, leave another app view, or carry startup/setup/default-tab work still -record the visit, and the sidebar reveal still runs for the no-op case. - -## Follow-up: ANSI Terminal Redraw Controls - -The installed app used for the continuation profile still predates the merged -memory diagnostics and terminal-preview fixes, but its live active terminal -preview continued to show redraw noise and there were still no browser tabs. -That kept the remaining source-backed target on retained terminal previews. - -The previous preview fix handled carriage-return and backspace redraws, but -not ANSI CSI erase/cursor sequences commonly emitted by spinner-style TUIs. -The follow-up extends the retained-preview line model to strip formatting/OSC -metadata and apply line erase plus horizontal cursor movement before retaining -the text tail. The regression test covers cursor-left overwrite, erase-line -without a carriage return, SGR/private cursor controls, OSC title metadata, and -the existing terminal read cursor metadata. diff --git a/docs/show-full-tab-title-tooltip.md b/docs/show-full-tab-title-tooltip.md deleted file mode 100644 index 96ea7a0d39f..00000000000 --- a/docs/show-full-tab-title-tooltip.md +++ /dev/null @@ -1,131 +0,0 @@ -# Show Full Tab Title Tooltip - -## Problem - -Issue #2966 reports that long tab titles are truncated with an ellipsis but do not expose the full name on hover or focus. - -- Terminal tab labels render in `src/renderer/src/components/tab-bar/SortableTab.tsx:324` as `truncate max-w-[72px]` with no tooltip. -- Browser tab labels render in `src/renderer/src/components/tab-bar/BrowserTab.tsx:163` as `truncate max-w-[100px]` with no tooltip. -- Editor tab labels render in `src/renderer/src/components/tab-bar/EditorFileTab.tsx:289` as `truncate max-w-[80px]` with no tooltip. -- The app already has a Radix-backed shadcn tooltip wrapper in `src/renderer/src/components/ui/tooltip.tsx:23`. -- The app-level `TooltipProvider` is mounted in `src/renderer/src/App.tsx` with `delayDuration={400}`; this feature should use that provider, not add another one. - -## Root Cause - -The tab components render plain text spans for their ellipsized labels. The full title exists in component state, but the focusable tab root is not wired to the shared `Tooltip`/`TooltipTrigger`/`TooltipContent` primitive, so neither hover nor keyboard focus exposes it. - -## Non-Goals - -- Do not change tab width, scrolling, drag-and-drop behavior, context menus, title generation, or rename semantics. -- Do not add persistence, IPC, or main-process behavior. -- Do not replace the tab bar layout or introduce a new tooltip primitive. -- Do not add custom overflow measurement in this pass unless a reviewed implementation finds the always-available tooltip materially harmful. - -## Design - -1. Add full-title tooltips to non-editing tabs in the existing tab components. - - Terminal: display `tab.customTitle ?? tab.title`. - - Browser: display `getBrowserTabLabel(tab)`. - - Editor: display `getEditorDisplayLabel(file)`, preserving preview italic and external mutation/status adornments outside the label. -2. Use the existing shadcn tooltip primitive from `@/components/ui/tooltip`. - - Do not add a nested `TooltipProvider`; the app root already supplies the 400ms delay required by the style guide. - - In non-rename states, wrap the sortable tab root with `...` so both pointer hover and keyboard focus on the dnd-kit focusable tab (`role`, `tabIndex=0`) surface the tooltip. - - Keep the tooltip gated off while a context menu is open. Right-click opens a Radix menu from the same root hover area, so the full-title tooltip must not remain visible over the menu. - - Do not make the label span itself the only trigger: it is not focusable today, and adding `tabIndex` to the label would create a second keyboard stop inside each tab. - - Render `full label`. - - `side="bottom"` is intentional because the tab strip sits at the top edge; Radix collision handling can still flip/shift when needed. - - Keep tooltip content non-interactive; it is only a label. -3. Keep edit states out of the tooltip path. - - Terminal rename input and editor rename input should remain direct inputs with no tooltip wrapper. Editor rename is nested inside an otherwise sortable root, so `isRenaming` must also disable the root tooltip, not only the label span tooltip. - - Double-click handlers for rename/pin must keep their current event behavior. -4. Preserve drag, activation, and close controls. - - `TooltipTrigger asChild` must attach to the existing sortable root element without introducing a new DOM wrapper, so `setNodeRef`, dnd-kit attributes/listeners, activation, context-menu capture, middle-click close, and close/collapse buttons keep their current event paths. - - Because wrapping the root makes the entire tab chrome a tooltip trigger, Electron validation must check the tooltip does not obscure or compete with close/collapse/context-menu affordances. The shared `TooltipContent` is already `pointer-events-none`, but visual overlap and native `title` on the terminal collapse button still need checking. - - The outer tab remains the sortable/activation target. -5. Add focused regression coverage for the render contract. - - Unit tests should assert that terminal, browser, and editor rendered tab roots are passed through the tooltip primitive with the correct full label. - - Tests can mock tooltip components to avoid Radix portal timing and assert structural wiring. Because these components call hooks and the Vitest environment is Node-only, render them with `react-dom/server` or another existing local pattern, and mock `@dnd-kit/sortable` so the root exposes deterministic `role`/`tabIndex` attributes. - -## Data Flow - -- Store/runtime tab state updates title fields. -- `TabBar` maps current visible tabs to `SortableTab`, `BrowserTab`, and `EditorFileTab`. -- Each tab component computes the displayed label. -- The non-editing sortable root remains the trigger so pointer hover and keyboard focus share the same tooltip path. -- Hover/focus opens Radix tooltip content containing the same full label after the app's normal 400ms delay. -- Title changes from runtime/browser/editor state re-render the same component; do not cache tooltip text outside the render path. - -## Edge Cases - -- Terminal custom title overrides the runtime title in both visible label and tooltip. -- Terminal title updates from remote/SSH PTY state should update the tooltip through normal React props. -- Browser tab with missing/blank title should show the URL-derived fallback or `New Tab`, matching the visible label. -- Browser tooltip text must use `getBrowserTabLabel(tab)` from the same `tab` prop as the visible label, not `getLiveBrowserUrl` or the redacted context-menu URL; otherwise live URL drift can make the tooltip disagree with the tab. -- Editor preview, dirty, git-status, conflict-review, diff, markdown-preview, and external-mutation labels should keep existing styling and adjacent badges. -- Rename modes must not show stale title tooltips over the input. -- Keyboard focus on a terminal, browser, or editor tab must show the same tooltip as hover. The existing dnd-kit root provides the focus target; do not add focusable descendants for tooltip-only behavior. -- Context menu, middle-click close, drag start, and split drop indicators must still work because the sortable root and pointer handlers remain unchanged. -- Very long tooltip text should wrap within a bounded width and stay readable in light/dark themes. -- Context-menu open must force the corresponding tooltip closed for terminal, browser, and editor tabs. -- Hovering root-child controls such as close, terminal collapse, loading indicators, dirty dots, and status badges may enter the root trigger; the tooltip must remain non-blocking and should not create duplicate/conflicting hover copy that makes those controls harder to use. -- Multi-window and web/SSH remote-client paths should need no IPC or persistence work because this is renderer-only display of existing tab state. The implementation must still derive text from current props every render so external title/file mutations do not leave stale tooltip content while a tab remains mounted. - -## Test Plan - -- Unit: add or update tab-bar component tests that mock tooltip primitives and assert: - - terminal tab label tooltip content uses custom title when present; - - browser tab label tooltip content uses `getBrowserTabLabel`; - - editor tab label tooltip content uses `getEditorDisplayLabel`; - - the tooltip trigger is the sortable root in non-rename mode, preserving dnd-kit `role`/`tabIndex` focusability; - - rename/context-menu suppression is unit-tested only where the implementation exposes that state through a small testable render path; do not contort Node-only server-render tests to fake private interactive state. -- Integration/Electron: create long terminal/browser/editor tab labels, hover each tab, wait for the app's 400ms tooltip delay, and verify a tooltip with the full label appears while tab activation/close still works. -- Integration/Electron: keyboard-focus one terminal, browser, and editor tab and verify the tooltip appears without adding an extra tab stop inside the label. -- Integration/Electron: enter terminal/editor rename modes and open terminal/browser/editor context menus, then verify the full-title tooltip is absent or dismissed. -- Regression: run `pnpm typecheck`, `pnpm lint`, and the relevant tab-bar Vitest tests via `pnpm test -- ` if focused files are added. - -## UI Quality Bar - -- Tooltip appears near the hovered/focused truncated label without covering the close button unnecessarily. -- Tooltip uses existing Orca token-based styling through `TooltipContent`; no new colors, shadows, or typography. -- Long text wraps at a readable max width, including long path/URL segments, and does not clip off-screen in a normal desktop viewport. -- Tab dimensions, hover colors, active indicator, unread bell, dirty dot, git status, and close-button reveal behavior do not shift. -- Light and dark theme surfaces remain legible. - -## Review Screenshots - -1. Terminal tab with a long custom or runtime title hovered, showing the full title tooltip. -2. Browser tab with a long page title or URL hovered, showing the full title tooltip. -3. Editor tab with a long filename/path-derived label hovered, showing the full title tooltip while dirty/status adornments still look unchanged. -4. Adjacent smoke state: a keyboard-focused tab shows the tooltip, and close/context-menu affordances still work after tooltip wiring. - -## Rollout - -1. Add tooltip imports and wrap the non-editing sortable tab roots in `SortableTab`, `BrowserTab`, and `EditorFileTab`. -2. Add focused tests for tooltip label wiring. -3. Run relevant tests, typecheck, and lint. -4. Validate in Electron with hover screenshots for terminal, browser, editor, and adjacent tab controls. - -## Lightweight Eng Review - -- Scope: kept to renderer tab-label rendering only. No overflow observer, persistence, IPC, or new primitive is needed to satisfy the issue. -- Architecture/data flow: title data already reaches the tab components; tooltip content should be derived in the same component that renders each visible label. This keeps terminal, browser, and editor ownership unchanged and avoids cross-window/SSH special cases. The trigger must be the focusable sortable root, not the inner label span, because the issue requires focus behavior. -- Failure modes covered: - - Tooltip root/trigger wiring could interfere with drag/activation if it adds an extra DOM wrapper or drops the dnd-kit ref/listeners/attributes. - - Rename mode could show stale tooltip content over an input if the tooltip is not gated out. - - Context-menu open could leave a delayed tooltip rendered over menu items unless `menuOpen` suppresses it. - - Long tooltip text could overflow the viewport if content width is unbounded. - - Browser fallback labels could drift if tooltip recomputes a different label than the visible span. - - Editor badges/status text could shift if the tooltip adds a block wrapper inside the label row. - - Root-level hover could show the tooltip while the pointer is over close/collapse/context-menu surfaces; validation must prove it does not block those controls or create unusable duplicate copy. -- Test coverage required: - - Unit/component: tab-bar label tooltip wiring for terminal, browser, and editor components. - - Unit/component: terminal custom title precedence, browser fallback label parity, and non-rename root trigger focusability. - - Electron validation: hover, keyboard-focus, rename suppression, context-menu suppression, and adjacent close/context-menu smoke for terminal/browser/editor labels. -- Performance/blast radius: small but not free. This adds one Radix tooltip root per visible tab and a few event handlers on the existing tab root; that is acceptable because tab count is bounded by rendered tabs and there is no IPC, polling, file watching, persistence, startup work, or overflow measurement. -- UI quality bar: existing tooltip primitive only, bounded wrapping, no tab-size shift, no overlap with close/action affordances, legible in light/dark. -- Required review screenshots: - 1. Long terminal tab tooltip. - 2. Long browser tab tooltip. - 3. Long editor tab tooltip with existing adornments intact. - 4. Keyboard-focus tooltip plus adjacent close/context-menu smoke state. -- Residual risks: Radix `TooltipTrigger asChild` must merge props/ref with the dnd-kit sortable root without disrupting pointer or keyboard drag behavior; unit tests with mocked dnd-kit can only prove wiring, so Electron validation must explicitly cover drag/activation adjacency, context-menu suppression, and focus-triggered display. diff --git a/docs/source-control-push-failure-ai-recovery.md b/docs/source-control-push-failure-ai-recovery.md deleted file mode 100644 index 136b7e0031a..00000000000 --- a/docs/source-control-push-failure-ai-recovery.md +++ /dev/null @@ -1,368 +0,0 @@ -# Source Control Push Failure AI Recovery - -## Problem - -Push, force-push, publish, and sync push-stage failures flow through the shared -remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94` -and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716` -and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or -lint hook is not an auth, protected-branch, or transport problem, so generic -remote guidance sends the user in the wrong direction. - -PR #7787 proves the behavior is useful, but the prototype needs tightening before -it is maintainable: - -- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and - `src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat - push-failure predicates and feed live branch/file state into a prompt that says - "at failure time". -- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63` - imports push detection again instead of consuming one derived recovery model. -- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24` - and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24` - are copied launch flows with action-specific copy. -- `src/shared/source-control-commit-failure-agent-command.ts:6` and - `src/shared/source-control-push-failure-agent-command.ts:6` are identical - command-template builders. -- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21` - already contains a reusable split button, while `SourceControl.tsx:6415` - carries a second inline version. -- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats - any `isSync` error as push-like for hook detection. `syncBranch` calls the - formatter for fetch, upstream-status, pull, and push failures, so the renderer - cannot prove a sync error came from the push stage without an explicit marker. -- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2` - imports prompt helpers through the large React module instead of shared prompt - modules. - -## Goal - -Add first-class recovery for pre-push hook failures without growing a parallel -push-only subsystem: - -1. Toasts for push, force-push, publish, and sync push-stage failures say - "blocked" for detected pre-push or lint-hook output. -2. Source Control shows a concise Push blocked panel with Details and AI Fix - only for a failure snapshot that is both push-like and hook-like. -3. AI Fix launches the configured `fixPushFailure` Source Control action with a - safe, provider-neutral prompt. -4. Commit-failure recovery and push-failure recovery share launch, - command-template, split-button, and dense error-panel primitives. - -## Non-goals - -- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side - protected-branch failures from this entry point. -- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a - PR, or assume GitHub-specific terminology. -- Do not add app-wide persistence for this transient failure panel. -- Do not redesign the Source Control panel or the Source Control AI settings - model. -- Do not fold unrelated launch surfaces such as checks recovery into this change - unless a tiny shared helper is already needed for commit/push recovery. - -## Design - -1. Keep push-hook detection and prompt building in shared code. - - `src/shared/source-control-push-failure.ts` owns normalization, bounded - scanning, summary text, Details eligibility, prompt truncation, prompt - rules, and changed-file list bounding. - - Detection must stay conservative: explicit `pre-push`/`prepush`, - `hook declined to push`, hook-runner output in push context, or lint output - in push context. Auth, non-fast-forward, protected branch, pre-receive, - submodule, and generic transport failures stay on the existing remote-error - path. - - Keep the output scan bounded at 64 KiB and the prompt failure-output - section bounded. Also cap prompt file lines to a small constant with an - omitted-count line; do not rely on the git-status cap, which is far too high - for prompt context. - -2. Make remote-error formatting sync-stage aware. - - Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for - sync, for example `isSyncPushStage`. - - Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or - `isSyncPushStage`; do not use bare `isSync` as evidence of a push failure. - - In `syncBranch`, pass the push-stage flag only inside the two inner - `pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull - catches still pass `isSync` for Sync-shaped generic copy but must not render - blocked hook copy. - - Carry the same push-stage fact back to the renderer, either by tagging the - thrown `Error` with a narrow exported marker or by wrapping it in a typed - error that preserves the original message/cause. `SourceControl` must set - `syncPushStage` only from that marker, not from `kind === 'sync'` plus - hook-like stderr. - - Keep submodule push messages before hook detection so submodule guidance - remains more specific than AI recovery. - -3. Replace one-off command builders with one shared recovery command builder. - - Add a shared builder that accepts `{ actionId, promptOverride, - commandInputTemplate, basePrompt }` and renders the existing Source Control - AI command template for launch actions. - - Keep compatibility exports for commit and push helpers if nearby tests or - call sites still use those names, but make them wrappers over the generic - builder. - - Move prompt-builder tests to import from shared modules, not - `SourceControl.tsx`. - -4. Capture and derive push recovery state once. - - Extend `SourceControlActionError` with the raw error, a push-stage marker - for sync failures, the branch name at failure time, a bounded status-entry - snapshot at failure time, and a per-worktree sequence token. - - `runRemoteAction` clears the worktree's previous error and increments its - sequence before starting. A catch may write an error only if its sequence - still owns that worktree, so a slow failure cannot overwrite a newer retry - or success. - - Add a focused renderer helper that accepts the captured - `SourceControlActionError` and the current branch name, then returns either - `null` or a model with raw/sanitized detail text, summary, details flag, - kind label, and AI prompt. - - The helper returns `null` unless the operation is `push`, `force_push`, - `publish`, or `sync` with the sync push-stage marker and - `isPushHookFailure(rawError)` passes. - - If the current branch is known and differs from the captured branch, hide or - clear the model instead of launching an agent with wrong branch context. - - Pass that single model to `useSourceControlAi` and `CommitArea`; neither - prop construction nor `CommitArea` should repeat the push-hook predicate. - -5. Use one generic recovery launcher for commit and push failures. - - Replace the separate commit and push launch files with - `source-control-ai-recovery-launch.ts`, or keep the old files as one-line - wrappers if import stability is cheaper. - - The generic launcher owns connection resolution, SSH/local agent discovery, - saved-agent validation, agent-args validation, agent selection, terminal - launch, focus, and success/failure toasts. - - Resolve connection without collapsing local and unresolved states: read - `const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it - is a string or `null`, and only fall back to `sourceRepoConnectionId` when - the worktree lookup returns `undefined`. If the fallback is also - `undefined`, show the workspace-connection error instead of launching - locally. `null` means proven local; a string means SSH/remote. - - Validate saved CLI args before agent detection or terminal creation. On - Windows, keep using PowerShell-safe planning through - `planAgentCliArgsSuffix(..., 'powershell')`. - - Action-specific data is limited to action id, base prompt, empty-prompt - copy, unavailable-agent copy, and success copy. - -6. Keep the recovery hook thin. - - `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the - already-derived push recovery prompt/model, keeps independent loading - flags, and calls the generic launcher. - - It should not import push-hook detection, resolve agents, or duplicate - launch plumbing. - -7. Extract and reuse the recovery UI. - - Reuse or replace the existing - `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx` - rather than adding another split-button component. - - Move the dense recovery notice and Details dialog into focused renderer - modules named for source-control recovery. Avoid `helpers`, `utils`, and new - max-lines suppressions. - - Reuse the same notice/dialog component for commit and push recovery with - action-specific labels, summary, details, prompt, saved recipe, and launch - callback. - - Keep normal remote errors filtered out when a push recovery model is - rendered, so the user does not see both Push blocked and generic remote - error copy for the same failure. - -8. Retain `fixPushFailure` in the existing Source Control AI action model. - - The prototype already adds `fixPushFailure` to - `src/shared/source-control-ai-actions.ts`; keep it as a launch action like - `fixCommitFailure`. - - Verify default settings, normalizers, global settings rows, repository - override rows, labels, descriptions, and variable chips all include Push - failure fixes. - - Do not create a push-specific settings store or migration. Existing - `normalizeSourceControlAiSettings` hydrates missing action defaults. - -## Data Flow - -- User clicks Push, Force Push, Publish Branch, or Sync. -- `runRemoteAction` clears that worktree's previous remote error, records a new - sequence token, and starts the editor-store remote action. -- Git push fails and throws. -- `pushBranch` or sync's inner push-stage catch calls - `resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull, - and upstream-status failures do not pass the push-stage flag. -- Sync's inner push-stage catch marks the rethrown error as push-stage so - `runRemoteAction` can preserve `syncPushStage` in the captured snapshot. -- The formatter classifies hook output before auth/transport fallbacks only for - push-like options and shows blocked toast copy. -- `SourceControl` catches the same error, and if the sequence still owns the - worktree, stores `{ kind, rawError, message, syncPushStage, branchName, - entriesSnapshot }`. -- The push recovery helper derives one `pushRecovery` model from that snapshot. -- `CommitArea` renders normal remote errors or the Push blocked recovery notice. -- AI Fix builds the `fixPushFailure` command input and launches the selected - agent in the owning local or SSH runtime. - -## Edge Cases - -- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and - transport failures must not show the Push blocked panel. -- Submodule push errors keep their existing specialized messages before - push-hook detection. -- Sync failures from fetch, upstream-status, or pull stages must not show Push - blocked, even if stderr contains words like "lint" or "hook". -- Create PR intent remote failures use the same `runRemoteAction` plumbing; if - CommitArea is not visible, blocked toast copy is still required but no hidden - panel work is needed. -- A newer remote operation for the same worktree must clear stale recovery state - immediately and prevent an older in-flight failure from writing after it. -- Switching worktrees or branches must not show another worktree or branch's - push failure. -- External edits or another Orca window may make the failure stale. Do not add - cross-window persistence for this feature; rely on local retry/remount/branch - mismatch clearing and list this as residual risk. -- Source Control AI hidden for a repo still shows the blocked notice and Details; - it hides AI Fix. -- SSH worktrees must detect and launch agents on the owning connection, not a - local fallback. -- Windows launch planning must continue to use PowerShell-safe agent args. -- Very large hook output and very large changed-file sets must be bounded before - prompt generation. -- ANSI/control output must not leak into summaries or Details comparison logic. - Details may show sanitized text while preserving enough line breaks for - debugging. -- Empty custom command templates must stay empty so the launcher rejects them - with a clear settings error. -- Prompt text must treat file paths, branch names, and hook output as data, not - instructions. - -## Test Plan - -- Shared unit tests: - - `src/shared/source-control-push-failure.test.ts` covers detection positives, - auth/protected/pre-receive/non-fast-forward negatives, ANSI/control - stripping, bounded scanning, details comparison, prompt output truncation, - prompt file-list capping, and provider-neutral prompt rules. - - Shared command-template tests cover the generic recovery builder plus - commit/push wrappers, including empty templates and prompt overrides. - - `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure` - label, default template, variables, normalization, and action-list inclusion. -- Remote formatter and store tests: - - `src/renderer/src/lib/source-control-remote-error.test.ts` covers push, - force-push, publish, and sync push-stage blocked copy plus sync non-push - stage negatives, auth/non-fast-forward/protected/pre-receive/submodule - regressions. - - `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast - path and both sync push-stage and sync non-push-stage toasts. -- Renderer model and UI tests: - - A focused push recovery derivation test covers operation kind, sync - push-stage marker, branch mismatch, stale sequence ownership, snapshot file - entries, summary, details flag, prompt contents, and ordinary remote errors. - - `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix - visible/hidden, ordinary remote errors, no duplicate remote error when a push - model exists, and unchanged commit-failure recovery rendering. - - Settings tests cover global and repository Source Control AI action rows - showing Push failure fixes. -- Launcher tests: - - Generic recovery launcher tests cover commit and push action ids, invalid CLI - args before detection, empty template rejection, unavailable saved agent, - local versus SSH agent detection, successful launch/focus, and success copy. -- Checks: - - Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and - `pnpm check:max-lines-ratchet`. - -## UI Quality Bar - -The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome, -quiet, dense, token-based, and consistent with adjacent commit-failure recovery. -Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide -icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action -recipe row patterns. The Push blocked notice should keep the summary readable at -narrow right-sidebar widths, avoid nested cards, avoid new color values, show -stable split-button geometry while launching, and keep Details as progressive -disclosure for long hook output. Dialogs and dropdowns must remain usable in -light/dark mode and under SSH latency. - -## Review Screenshots - -1. Source Control after a simulated pre-push hook failure: Push blocked notice - with summary, AI Fix, and Details. -2. Push failure Details dialog showing hook output and Fix with AI. -3. AI Fix customize-launch dialog for `fixPushFailure`. -4. Ordinary push auth/protected/remote failure state: regular remote error, no - Push blocked notice. -5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push - blocked notice. -6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI - Fix. -7. Source Control AI settings showing the Push failure fixes launch recipe. -8. Adjacent commit-failure recovery notice still rendering correctly after the - shared UI extraction. - -## Rollout - -1. Tighten shared push-failure classifier/prompt bounds and add the generic - recovery command builder. -2. Make remote formatter/store sync-stage aware and update toast tests. -3. Add the `SourceControlActionError` snapshot/sequence data and one push - recovery derivation helper. -4. Replace duplicated commit/push launchers with the generic recovery launcher - and thin hook wiring. -5. Extract/reuse source-control recovery UI components, including the existing - split-button module. -6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels. -7. Update targeted tests and run typecheck, lint, and max-lines ratchet. -8. Validate rendered Electron states with screenshots before opening the PR. - -## Lightweight Eng Review - -- Scope: Reduced from PR #7787's push-specific parallel path to a shared - recovery layer. Keep the feature limited to local pre-push/lint-hook detection, - blocked copy, an inline recovery notice, Details, and AI launch. No persistence - or provider-specific recovery. -- Architecture/data flow: Shared code owns classification, prompt text, and - command-template rendering; the editor store owns stage-aware toast formatting; - `SourceControl` owns transient worktree-scoped failure snapshots and sequence - invalidation; the generic launcher owns local/SSH agent discovery and terminal - launch. -- Failure modes covered: - - Sync stage ambiguity: only the inner push stage may classify as hook-blocked. - - Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay - out of push recovery. - - Branch or worktree switches suppress stale recovery models. - - Per-worktree sequence tokens prevent older in-flight failures from - overwriting newer retries or successes. - - Empty custom templates, invalid CLI args, unavailable saved agents, missing - worktree context, SSH host detection, and Windows shell planning fail before - terminal launch. - - Large or hostile hook output and file lists are bounded and treated as data - in prompts. -- Test coverage required: - - Shared classifier/prompt/command/action-model tests in `src/shared`. - - Remote formatter and editor-store tests for push, force-push, publish, sync - push-stage, and sync non-push-stage paths. - - Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix, - hidden AI actions, stale branch/sequence, ordinary remote errors, and - existing commit failure recovery. - - Generic launcher tests for local/SSH, invalid args, saved-agent unavailable, - empty template, and success launch. - - Settings tests for global and repo Push failure fixes rows. - - Electron validation for all required screenshots; no separate E2E is - required if renderer tests cover the model and Electron validates the real UI. -- Performance/blast radius: No polling, watchers, migrations, or startup work. - Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt - output, and a bounded file snapshot only when a remote action fails. Largest - blast radius is the shared recovery UI and launcher used by existing commit - failures, so commit recovery needs tests and a screenshot. -- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density: - token-based error state, stable split-button geometry, progressive Details - disclosure, no nested cards, concise copy, light/dark compatibility, and - visible disabled/loading state under SSH latency. -- Required review screenshots: - 1. Push blocked notice after pre-push hook failure. - 2. Push failure Details dialog with Fix with AI. - 3. `fixPushFailure` customize-launch dialog. - 4. Ordinary push auth/protected/remote failure with no Push blocked panel. - 5. Sync non-push-stage failure with no Push blocked panel. - 6. Source Control AI hidden: Push blocked without AI Fix. - 7. Source Control AI settings showing Push failure fixes. - 8. Existing commit-failure recovery notice after shared UI extraction. -- Residual risks: Classifier false positives remain possible for unusual local - hook-runner output that mentions push context; keep tests biased toward - preserving auth, protected-branch, and non-fast-forward behavior. Failure - panels are renderer-local, so another Orca window or an external terminal can - fix the problem while the old panel remains visible until local retry, remount, - or branch/worktree change. Electron validation may need a mocked or temporary - repo scenario to trigger a real pre-push hook without mutating user data. diff --git a/docs/ssh-config-target-compatibility.md b/docs/ssh-config-target-compatibility.md deleted file mode 100644 index 07817dc41f7..00000000000 --- a/docs/ssh-config-target-compatibility.md +++ /dev/null @@ -1,61 +0,0 @@ -# SSH Config Target Compatibility - -## Problem - -- `src/main/ssh/ssh-connection-utils.ts:108` resolves OpenSSH config with `ssh -G`, but then prefers the persisted `target.host` over `resolved.hostname`. -- `src/main/ssh/ssh-connection-utils.ts:109` treats persisted port `22` as an explicit override, so a config-host target can ignore a resolved non-default `Port`. -- `src/main/ssh/ssh-config-parser.ts:210` imports config aliases with `host` set to the alias when the concrete `Host` block lacks an inline `HostName`; later `ssh -G` may know the real host, but the connection path ignores it. -- `src/renderer/src/components/settings/SshPane.tsx:63` requires both host and username, even though OpenSSH config aliases can resolve the user and users commonly paste `user@host:port` targets. -- `src/main/ssh/ssh-config-parser.ts:330` parses `ForwardAgent`, but `src/main/ssh/ssh-connection-utils.ts:112` did not pass it into ssh2, so remote git commands that rely on the local agent could fail. - -## Goal - -Make Orca behave like a mature SSH client for common config-host flows: aliases imported from `~/.ssh/config`, aliases with inherited `HostName`/`Port`, and pasted SSH targets should connect without users re-entering information that OpenSSH can resolve. - -## Non-goals - -- Do not add persistent secret storage for SSH passwords or key passphrases. -- Do not redesign the whole SSH settings page. -- Do not change relay deployment or remote PTY lease semantics. -- Do not add a known-host trust UI in this patch. - -## Design - -1. Preserve explicit target overrides while letting config aliases use resolved values. - - In `buildConnectConfig`, prefer `resolved.hostname` only when the persisted host is blank, the same as `configHost`, or the same as the label. - - Prefer `resolved.port` when the target is a config-host target still on the default `22`; keep non-default target ports as explicit overrides. - - Continue using target username first, then resolved user. - -2. Honor resolved agent forwarding where ssh2 can support it. - - Set `agentForward` only when resolved config requested forwarding and an agent is actually configured. - - Leave system-SSH transport unchanged because it already delegates to OpenSSH config. - -3. Normalize settings form drafts before save. - - Accept `ssh://user@host:port`, `user@host:port`, and plain aliases in the Host field. - - Auto-fill username and port from pasted inputs only when the dedicated fields are still empty/default. - - Allow username to be omitted; `ssh -G` can provide it during connect. - -4. Keep UI changes small. - - Rename copy only where needed to avoid implying username is mandatory. - - Render username-less targets without a leading `@`. - - Do not introduce new colors, typography, or layout patterns. - -5. Cover behavior with focused tests. - - Add connection-config tests for config-host resolved hostname/port precedence. - - Add connection-config tests for `ForwardAgent yes`. - - Add renderer utility tests for pasted SSH target normalization. - -## Edge Cases - -- Explicit non-default port in Orca still wins over `ssh -G`. -- Empty or unparsable host input remains invalid. -- IPv6 bracket syntax is accepted for `ssh://` URLs and preserved conservatively for scp-like inputs. -- Plain config aliases remain valid even without a username. - -## Rollout - -1. Add the renderer draft-normalization helper and tests. -2. Wire the SSH settings form save path and labels to the helper. -3. Update `buildConnectConfig` precedence/agent-forwarding and tests. -4. Clean up username-less target display. -5. Run focused tests and typecheck/lint where feasible. diff --git a/docs/ssh-handler-reregistration-port-forwards.md b/docs/ssh-handler-reregistration-port-forwards.md deleted file mode 100644 index f45e0120151..00000000000 --- a/docs/ssh-handler-reregistration-port-forwards.md +++ /dev/null @@ -1,243 +0,0 @@ -# SSH Handler Re-registration Port Forwards - -## Problem - -Issue #2932 reported that macOS window reactivation can re-run -`attachMainWindowServices`, which calls `registerSshHandlers` again -([src/main/window/attach-main-window-services.ts:83](src/main/window/attach-main-window-services.ts:83)). - -Before this change, `registerSshHandlers` removed and re-added IPC handlers but -also replaced the module-level `connectionManager` and `portForwardManager` -([src/main/ipc/ssh.ts:438](src/main/ipc/ssh.ts:438), -[src/main/ipc/ssh.ts:439](src/main/ipc/ssh.ts:439)). -`activeSessions` remains module-global -([src/main/ipc/ssh.ts:63](src/main/ipc/ssh.ts:63)), -so live relay sessions kept references to the old port-forward manager while -new IPC handlers read a fresh empty one. - -The visible failure is: - -1. Connect an SSH target. -2. Add a local port forward. -3. Close all windows on macOS while the app process remains alive. -4. Reactivate Orca, causing SSH handlers to register again. -5. `ssh:listPortForwards` returns an empty list, `ssh:removePortForward` cannot - remove the old forward id, `ssh:addPortForward`/`ssh:updatePortForward` can - fail because the fresh connection manager has no live connection, and - `ssh:disconnect` does not close the old SSH connection or local listener. - Re-adding the same local port fails because the old server remains bound. - -## Root Cause - -SSH handler registration mixes two lifetimes: - -- Process-lifetime session state: active SSH connections, relay sessions, port - listeners, relay lost backoff, reset/connect in-flight maps. -- Window-lifetime callback state: `getMainWindow` and renderer IPC handlers. - -The previous re-registration path preserved `activeSessions` but replaced the -managers that sessions and IPC handlers must share. Port-forward IPC operations -use `portForwardManager` ([src/main/ipc/ssh.ts:992](src/main/ipc/ssh.ts:992), -[src/main/ipc/ssh.ts:1047](src/main/ipc/ssh.ts:1047), -[src/main/ipc/ssh.ts:1056](src/main/ipc/ssh.ts:1056)), -and disconnect/terminate cleanup also uses that variable -([src/main/ipc/ssh.ts:750](src/main/ipc/ssh.ts:750), -[src/main/ipc/ssh.ts:814](src/main/ipc/ssh.ts:814)). -After replacement, those operations no longer targeted the manager that owns the -live local servers. Replacing `connectionManager` also strands the live -`SshConnection` objects: existing relay sessions still hold their current -connection, but new IPC handlers and `getSshConnectionManager()` see an empty -manager. - -## Non-goals - -- Do not change relay protocol, remote deployment, or SSH transport behavior. -- Do not redesign port-forward persistence or enrichment. -- Do not change renderer UI. -- Do not introduce a second SSH service layer. -- Do not force-dispose live SSH sessions merely because a window was recreated. - -## Design - -1. Preserve process-lifetime managers across handler re-registration. - Instantiate `SshConnectionManager` and `SshPortForwardManager` only when - absent; later `registerSshHandlers` calls reuse the existing instances. - -2. Refresh every live callback owner on re-registration. This is required; a - plain `connectionManager ??= new SshConnectionManager(callbacks)` is not - enough. - - `SshConnectionManager` must update callbacks used by both future and - existing `SshConnection` objects, either via explicit `setCallbacks` methods - on manager/connection or via a stable callback proxy whose implementation - is mutable. - - Existing `SshRelaySession` objects must refresh `getMainWindow`, store, - runtime, and detected-port callback references. Event handlers must call - the current callback at event time; do not capture the old `getMainWindow` - in long-lived provider callbacks. - - The credential-request tracking set must not be per-registration if live - connections can switch callbacks during an in-flight `ssh:connect`. - -3. Re-register IPC handlers and dependent global listeners on every call. - `ipcMain` handlers, advertised URL refresh, credential IPC, browse handler, - and power-monitor listeners are window-registration concerns and should still - point at the latest window. - -4. Preserve existing explicit teardown behavior. - `ssh:disconnect`, `ssh:terminateSessions`, `ssh:removeTarget`, reset, and - double-connect cleanup must still remove forwards through the shared manager - before detaching or disposing sessions. - -5. Add regression tests in `src/main/ipc/ssh.test.ts`. - Connect a target, add a mocked port forward, call `registerSshHandlers` - again, then assert: - - `ssh:listPortForwards` still returns the original forward. - - `ssh:removePortForward` can remove the original id. - - `ssh:addPortForward`/`ssh:updatePortForward` still use the original live - connection. - - A second re-registration followed by `ssh:disconnect` still calls - `removeAllForwards` and `disconnect` on the original shared managers. - - State, credential, PTY, and detected-port callbacks from an existing live - session publish to the newest window after re-registration. - -## Data Flow - -- First registration: - - `registerSshHandlers(store, getWindowA)` creates store wrapper, connection - manager, port-forward manager, handlers, listeners, and current callback - environment. - - `ssh:connect` creates a relay session with the shared port-forward manager. - - `ssh:addPortForward` stores a local server in that same manager. - -- Window reactivation: - - `registerSshHandlers(store, getWindowB)` removes/re-adds IPC handlers. - - Existing managers are reused. - - Existing connection and relay-session callback owners are refreshed to the - latest store/runtime/window environment. - - New handlers close over `getWindowB` and call the same managers. - -- Cleanup: - - `ssh:removePortForward` and `ssh:disconnect` operate on the same manager - that owns the live forward, then broadcast through the latest window. - -## Edge Cases - -- Re-registration while a target is connected and has active port forwards. -- Re-registration while `ssh:connect`, `restorePortForwards`, reset, reconnect, - or disconnect is in flight. The operation must not split credential tracking - or create a session that holds stale callbacks. -- Re-registration while no targets are connected. -- Re-registration after the store object changes. Either update existing relay - sessions to use the new store/runtime or document and test the stronger - invariant that production re-registration always passes the same process - store/runtime. -- Re-registration after the window changes. All broadcasts, credential prompts, - PTY events, detected-port events, advertised URL refreshes, relay-loss state - changes, and terminal relay errors must use the newest `getMainWindow`. -- Disconnect after re-registration must release old local ports. -- `ssh:connect` after window reactivation must be idempotent when the existing - session is already ready and healthy: return the connected state without - tearing down forwards. Explicit reset/reconnect or non-ready replacement paths - must still await old port teardown before restoring forwards. -- `getSshConnectionManager()` consumers must continue to see live connections - after re-registration. -- Test isolation must not depend on module-singleton state leaking between - tests. Add explicit reset/teardown support if preserving managers makes - `beforeEach(registerSshHandlers)` insufficient. -- SSH and relay paths must keep working for remote targets; the fix must not - assume local filesystem or local-only execution. -- Windows/Linux remain unaffected: re-registration can still happen during - development or future window lifecycles, and the fix must avoid path or - platform assumptions. - -## Test Plan - -- Unit: `pnpm vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts` - - Add regression coverage for list/remove/disconnect after handler - re-registration. - - Add coverage that add/update after re-registration uses the still-live - connection manager connection, not a fresh empty manager. - - Add coverage that existing connection/session callbacks publish to a second - mock window after re-registration. - - Add an in-flight connect or credential-request test if callback refresh uses - mutable callback objects. - - Existing connect, disconnect, reset, relay-loss, and terminate tests cover - adjacent lifecycle behavior. -- Typecheck: `pnpm typecheck`. -- Lint: `pnpm lint`. -- Electron/SSH validation: use an existing SSH target such as `openclaw 2` if - available in the running app, add a disposable local port forward, trigger - window/service re-registration by closing and reopening the main window on - macOS, then verify the forward remains listed and removable. IPC/unit tests - are supporting evidence only; if the golden path cannot be exercised safely, - halt before PR and report the missing evidence. - -## UI Quality Bar - -Not UI-visible. No layout, copy, or visual styling changes are expected. The -only user-visible expectation is that existing SSH port-forward rows remain -present and actionable after window reactivation. - -## Review Screenshots - -1. SSH target connected with a port forward listed before re-registration. -2. Same SSH target after window reactivation, showing the same port forward - still listed. -3. Same SSH target after removing the port forward, showing it gone without an - error. - -## Rollout - -1. Add the focused regression test to prove the current lifecycle bug. -2. Change SSH handler registration to reuse process-lifetime managers. -3. Run the focused test, then typecheck and lint. -4. Validate in Electron against an SSH target if feasible; otherwise halt - before PR if the golden-path SSH UI cannot be exercised. - -## Lightweight Eng Review - -- Scope: reduced to SSH IPC lifecycle only. No relay, renderer, or persistence - redesign is needed because the broken boundary is manager replacement during - handler re-registration. -- Architecture/data flow: process-lifetime managers stay module-level and are - reused; window-lifetime IPC handlers/listeners are refreshed; existing - connection and relay-session callback owners must also be refreshed or proxied - so live events target the current BrowserWindow. -- Failure modes covered: - - Active forwards becoming invisible after re-registration. - - `ssh:removePortForward` missing the old forward id. - - `ssh:addPortForward`/`ssh:updatePortForward` failing against a fresh empty - connection manager. - - `ssh:disconnect` failing to close old SSH connections and local listeners - after re-registration. - - Store/window/runtime callback refresh after re-registration. - - Re-registration during in-flight connect/reset/reconnect. - - No-session re-registration continuing to work. -- Test coverage required: - - Unit in `src/main/ipc/ssh.test.ts` for connect/add/list/remove across - `registerSshHandlers` calls. - - Unit in `src/main/ipc/ssh.test.ts` for disconnect cleanup after - re-registration. - - Unit in `src/main/ipc/ssh.test.ts` for existing live callbacks reaching the - newest window after re-registration. - - Unit in `src/main/ipc/ssh.test.ts` for no-session re-registration and test - teardown/reset of module singletons. - - Existing lifecycle tests for reset, terminate, relay loss, and sleep remain - adjacent coverage. -- Performance/blast radius: no material startup or IPC cost. Reusing managers - avoids leaked runtime state and does not add polling, watchers, or - cross-process calls. Callback refresh is O(number of live SSH connections and - sessions) per registration, which should be tiny. -- UI quality bar: not UI-visible; preserve existing SSH port-forward UI state - rather than changing layout or copy. -- Required review screenshots: - 1. Connected SSH target with active port forward before re-registration. - 2. Connected SSH target with same port forward after re-registration. - 3. Connected SSH target after removing that forward. -- Feasibility: one-time manager creation is feasible only with callback refresh - for existing `SshConnection` and `SshRelaySession` instances. If that refresh - proves larger than expected, prefer a stable callback proxy over recreating - managers; do not dispose live sessions just to make callback ownership easier. -- Residual risks: Electron validation may be constrained by availability of an - existing SSH target and by avoiding live-user port collisions. If the golden - path cannot be exercised safely, stop before opening a PR and report the - missing manual evidence. diff --git a/docs/terminal-close-confirmation.md b/docs/terminal-close-confirmation.md deleted file mode 100644 index da6ce20f2bf..00000000000 --- a/docs/terminal-close-confirmation.md +++ /dev/null @@ -1,114 +0,0 @@ -# Terminal Close Confirmation - -## Problem - -- `CloseTerminalDialog` repeats the action users already requested with "Close Terminal?" and a generic "process will be killed" warning (`src/renderer/src/components/terminal-pane/CloseTerminalDialog.tsx:30`). -- `Cmd/Ctrl+W` closes only the focused split pane, or the tab when it is the last pane; the guard exists so tab-level close does not kill every pane by accident (`src/renderer/src/components/terminal-pane/keyboard-handlers.ts:364`). -- The running-process guard probes the PTY over the active runtime/SSH path and shows the dialog only when child processes exist (`src/renderer/src/components/terminal-pane/TerminalPane.tsx:781`). -- There is no way for power users to say "I understand, close it next time" even though similar destructive workflows persist skip-confirm settings (`src/shared/types.ts:2522`, `src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx:58`). - -## Goal - -Make the terminal close confirmation communicate the consequence, respect focused-pane scope, and allow users to disable future running-process close confirmations from the dialog or Settings. - -## Non-goals - -- Do not change window-close behavior; whole-window shutdown intentionally bypasses the child-process dialog. -- Do not change idle-shell behavior; idle shells still close immediately. -- Do not add bulk-close UI for this change. -- Do not introduce provider-specific agent kill logic; closing still uses the existing terminal close path. -- Do not add telemetry. - -## Design - -1. Add a persisted `skipCloseTerminalWithRunningProcessConfirm` boolean to `GlobalSettings`, defaulting to `false`. -2. Keep the existing child-process probe. If the new setting is true, close immediately after the probe reports child processes instead of showing the dialog. -3. Track the pending close as `{ paneId, copyKind }`, where `copyKind` is `agent` only when `agentStatusByPaneKey[makePaneKey(tabId, leafId)]` has a live non-unknown `agentType`; otherwise it is `command`. -4. Update dialog copy: - - command: title `Stop running command?`, body `Closing this terminal will stop the command running inside it.`, destructive button `Stop and Close`. - - agent: title `Stop this agent?`, body `Closing this terminal will stop the agent's current work.`, destructive button `Stop Agent`. -5. Add a checkbox: `Don't ask again for running terminals`. When checked and confirmed, persist `skipCloseTerminalWithRunningProcessConfirm: true` before closing the pane. -6. Add a Terminal Interaction settings switch: `Ask Before Closing Running Terminals`, checked when the skip flag is false. -7. Add the new setting to terminal settings search so "confirm", "close", "running", "agent", and "command" find it. - -## Data flow - -- `Cmd/Ctrl+W` or pane close action -- `TerminalPane.handleRequestClosePane(paneId)` -- Get `ptyId`; no PTY closes immediately -- `inspectRuntimeTerminalProcess(settings, ptyId)` -- No child processes closes immediately -- Child processes + skip setting closes immediately -- Child processes + confirmation enabled opens `CloseTerminalDialog(copyKind)` -- Confirm optionally persists skip flag, then calls `executeClosePane(paneId)` - -## Edge cases - -- If process inspection rejects, preserve the existing fallback: close the pane instead of trapping the shortcut. -- If the pane is removed before the dialog confirms, `executeClosePane` already no-ops when the manager cannot close it. -- For split panes, only the active pane gets the prompt and closes. -- For last-pane tabs, confirming still delegates to `onCloseTab`. -- Agent copy appears only from live pane status. Freshly launched agents that have not emitted hooks yet may use command copy; that is acceptable because the consequence is still accurate. -- SSH/runtime-host terminals still use the existing runtime process inspection; the setting lives in global renderer settings and is passed through the same update path. -- The skip flag affects only terminal running-process close confirmations, not workspace deletion, automation deletion, window close, or future bulk-close prompts. - -## Test plan - -- Unit/component: - - `CloseTerminalDialog` renders command copy, agent copy, checkbox, and reports the checked state on confirm. - - Settings search includes the running-terminal confirmation entry. - - Default settings include `skipCloseTerminalWithRunningProcessConfirm: false`. -- Integration/lightweight: - - Verify `TerminalPane` opens agent copy when live pane status has `agentType` and command copy otherwise. - - Verify checked confirm persists the skip flag before closing. -- Electron: - - Running command + default setting shows command confirmation. - - Running command + checkbox checked confirms and future close skips the dialog. - - Agent pane with live status shows agent confirmation copy. - - Idle shell closes without confirmation. - -## UI quality bar - -- Dialog uses existing shadcn `Dialog` and `Button` primitives, token colors, and current compact modal sizing. -- Copy names the destructive consequence first and avoids implying every terminal/tab/window will close. -- Checkbox is visually subordinate to the message and aligned with existing dense dialog spacing. -- Settings row matches neighboring Terminal Interaction switch rows and is searchable. -- No layout shift, clipping, or button text overflow at the current modal width. - -## Review screenshots - -1. Running-command confirmation dialog. -2. Running-agent confirmation dialog. -3. Terminal Interaction settings row for `Ask Before Closing Running Terminals`. - -## Rollout - -1. Add shared setting type/default. -2. Add dialog copy modes and checkbox. -3. Wire `TerminalPane` to derive copy kind, honor skip flag, and persist "don't ask again" on confirm. -4. Add Terminal settings row and search entry. -5. Add targeted tests. -6. Force-add this design doc when staging because root `.gitignore` treats new `docs/**` files as local-only by default. - -## Lightweight Eng Review - -- Scope: kept focused on the existing running-process confirmation; no new close routing, bulk-close behavior, or native window-close changes. -- Architecture/data flow: renderer-only UI setting rides the existing settings persistence path; process detection remains owned by `inspectRuntimeTerminalProcess` so SSH/runtime compatibility does not fork. -- Failure modes covered: - - process-inspection rejection preserves current close fallback - - stale pane between prompt and confirm no-ops through existing manager guard - - split-pane close remains pane-scoped - - missing/stale agent status falls back to generic command copy - - skip flag is scoped to terminal running-process confirmations only -- Test coverage required: - - component test for `CloseTerminalDialog` copy/checkbox - - shared default/type coverage via existing typecheck plus default-setting assertion - - settings search test for new discoverable entry - - focused TerminalPane behavior test if practical; otherwise Electron validation covers prompt routing -- Performance/blast radius: no polling, IPC, startup, or renderer-jank impact; only an extra settings boolean read during an already user-triggered close path. -- UI quality bar: Electron validation should judge the modal and Terminal settings row against `docs/STYLEGUIDE.md`, existing `Dialog`/`Button`/`SettingsSwitchRow`, and adjacent Terminal Interaction density. -- Required review screenshots: - 1. Running-command confirmation dialog - 2. Running-agent confirmation dialog - 3. Terminal Interaction settings row -- Residual risks: agent-specific copy depends on live hook status, so newly launched or manually run agents can still receive generic command copy; the design doc is ignored by default and must be force-staged for the PR. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md deleted file mode 100644 index e3e7a125988..00000000000 --- a/docs/terminal-main-owned-state.md +++ /dev/null @@ -1,99 +0,0 @@ -# Terminal Main-Owned State - -This document covers the hidden-output recovery slice. The broader terminal -model/view boundary is defined in -[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). - -## Problem - -Hidden and background terminal panes cannot rely on renderer memory as the only -place that terminal output exists. Chromium may throttle a hidden Electron -document while the PTY continues producing bytes. If the renderer retains every -hidden byte until xterm can parse it, a noisy terminal can pin large strings in -renderer memory and stall or crash the app. - -The renderer must keep a hard memory bound, but the user-visible terminal state -should still be recoverable when the pane becomes visible again. - -## Reference Pattern - -Use a host-owned terminal model as the recovery source and treat the renderer as -a view: - -- The host process receives PTY bytes first and appends them to a bounded - headless terminal model. -- The renderer writes visible output directly for low latency. -- Hidden renderer queues are bounded. When they overflow, the renderer marks its - xterm as stale and drops further hidden bytes instead of retaining them. -- On visibility resume, the renderer asks the host for a serialized snapshot, - clears its xterm, replays that snapshot, then resumes live writes. -- Live output racing with restore carries a monotonic sequence number, so bytes - already included in the snapshot are not written twice. - -This gives the foreground path the same latency profile as today, bounds hidden -renderer memory, and preserves terminal state from the host-owned model instead -of depending on an unbounded renderer backlog. - -## Requirements - -- Renderer hidden-output memory is capped per terminal. -- A hidden flood must not grow the renderer by retaining strings or chunk arrays - past the cap. -- Restoring a stale renderer must use main/runtime state for local, daemon, and - SSH PTYs. -- Remote runtime PTYs that do not have local main-owned state must keep the - existing warning fallback rather than pretending recovery is available. -- Restore must avoid xterm query auto-replies reaching the shell. -- Clear, resize, exit, and pane disposal must clean up recovery state. -- The SSH path must participate in the same sequencing and snapshot behavior as - local PTYs. - -## Chosen Design - -The existing runtime headless terminal is the main-owned model. Every PTY byte -already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for -local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated -and can serialize it. - -Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default -on — see the contract's Architecture Status), main drops hidden renderer-bound -bytes after model ingestion and emits an out-of-band restore marker, so a -gated hidden pane accumulates no renderer backlog at all. The overflow path -below is the fallback for kill-switch-off mode and for hidden PTYs with an -active delivery-interest sidecar. - -The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: - -1. The scheduler replaces the queued backlog with a small warning fallback. -2. The terminal connection marks that pane as needing main-state recovery. -3. Further hidden bytes for that stale pane are not enqueued in the renderer. -4. When the pane/document becomes visible, the connection requests a main-owned - snapshot, clears xterm, replays the snapshot under the replay guard, and - sends the normal post-reattach reset. -5. Live foreground chunks that arrive while restore is in flight are retained in - a small bounded queue. After snapshot replay, sequence numbers decide which - chunks were already included and which still need to be written. - -If the main snapshot is unavailable, the small warning fallback remains the -visible behavior. That is the expected fallback for surfaces without local -main-owned terminal state. - -## Non-Goals - -- This does not add a durable full byte log. The host-owned model is bounded - terminal state, not an infinite transcript. -- This does not throttle the PTY producer. Producer backpressure can be added - later with ACKs if we need to reduce host-side work during extreme floods. -- This does not change foreground terminal write latency. - -## Verification - -- Unit-test scheduler overflow callback behavior and chunk-count bounding. -- Unit-test renderer recovery so hidden overflow drops renderer backlog, fetches - the main snapshot on visibility/foreground resume, and does not duplicate - sequenced live output. -- Unit-test main IPC snapshot sequencing. -- Run terminal scheduler and PTY connection tests. -- Run typecheck. -- Exercise the Electron hidden-flood repro and confirm renderer memory stays - bounded while the recovered terminal shows the host-owned terminal state. diff --git a/docs/terminal-scroll-intent-architecture.md b/docs/terminal-scroll-intent-architecture.md deleted file mode 100644 index b686f240546..00000000000 --- a/docs/terminal-scroll-intent-architecture.md +++ /dev/null @@ -1,329 +0,0 @@ -# Terminal Scroll Intent Architecture - -## Problem - -Terminal panes can jump or jitter when a user switches workspaces while a TUI -pane is scrolled near, but not exactly at, the bottom. The most visible case is a -Codex-style alternate-screen TUI in a split pane: - -1. The user scrolls slightly up from the bottom. -2. The user switches to another workspace. -3. The user switches back. -4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a - wrong position, or restores an older viewport. - -The current code tries to repair the viewport after layout, fit, split -reparenting, hidden-output replay, and visibility resume. Those repairs compete -with each other because none of them owns the user's scroll intent. A delayed -restore can replay an old state after the user has already scrolled, while a -follow-output path can force the bottom even when the user is reading scrollback. - -## Reference Investigation Summary - -The useful reference pattern is not a timer-based restore loop. It is an -explicit pinned-to-bottom model owned by the terminal frontend: - -- The frontend stores bottom-following state separately from xterm's transient - `viewportY`. -- `xterm.onScroll` is not used as user intent. In xterm, that event can be - content-driven and can briefly report bottom during fast output or layout work. -- User wheel and keyboard scroll commands update the intent. -- `scrollToBottom()` is an explicit command that sets the intent back to - follow-output. -- Every write snapshots the intent before writing. If the terminal was following - output, it scrolls to bottom after the write. If the user was pinned to a - viewport, it preserves the prior viewport line. -- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise - preserve the current viewport. -- Alternate-screen state is tracked from `buffer.active.type`, but it is not a - reason to infer user scroll intent from `onScroll`. - -The reference implementation also patches private xterm internals to disable -xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll -only from explicit owner paths. That gives a clean ownership boundary, but it is -an escalation point for Orca because private internals increase xterm upgrade -risk. - -## Current Orca Risk Points - -The current Orca branch already has several independent scroll actors: - -- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`, - `baseY`, bottom state, and sometimes a marker, then restores using immediate, - rAF, and timeout paths. -- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores - scroll around `safeFit()`. -- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split - reparent restores across rAF and timeout phases. -- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts` - listens to `terminal.onScroll` and stores snapshots while visible. -- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output - through foreground, background, hidden-output skip, snapshot replay, and - restore paths. -- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can - write immediately, enqueue foreground writes, coalesce synchronized output, or - drain hidden/background chunks later. - -The biggest architectural mismatch is the visibility memory hook's use of -`terminal.onScroll` as a snapshot trigger. That event is not a reliable user -scroll signal. During workspace switching, output parsing, hidden replay, or fit -can make it persist transient positions that later appear as "older position" -restores. - -## Design Goal - -Make terminal viewport movement a result of explicit scroll intent, not a side -effect of visibility, output, or layout timing. - -The terminal should have one active scroll intent per pane: - -- `followOutput`: the terminal is logically pinned to the live output bottom. - New output, explicit focus-follow requests, and fits may keep it at bottom. -- `pinnedViewport`: the user is reading a specific viewport. New output, - workspace switches, hidden-output replay, and fits must not move the viewport - except when scrollback pruning or buffer replacement makes the exact line - impossible. - -## State Model - -Add a focused module, for example: - -`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts` - -Suggested state: - -```ts -export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' - -export type TerminalScrollIntent = { - kind: TerminalScrollIntentKind - bufferType: 'normal' | 'alternate' - viewportY: number - baseY: number - capturedAt: number -} -``` - -The state should be keyed by the live `Terminal` or by pane leaf identity where -it needs to survive pane replacement. For normal workspace switches that keep -the same xterm instance, the live terminal-keyed state should be authoritative. - -## Intent Transitions - -Only these events should change scroll intent: - -- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write - callbacks or rAF can pull the viewport back down. -- User wheel scrolls downward: after xterm applies the scroll, recompute whether - the viewport reached bottom. If yes, set `followOutput`; otherwise keep - `pinnedViewport`. -- User keyboard scroll commands: apply the same rules as wheel. -- Explicit `scrollToBottom()`, "follow output", or focus command with - follow-output semantics: set `followOutput`. -- Programmatic `scrollToTop()` or page/line scroll commands: set - `pinnedViewport`, unless the resulting viewport is bottom. -- Buffer change to alternate screen: record `bufferType`, but do not infer - follow-output from `onScroll`. -- Buffer return to normal screen: recompute from current viewport only if no - stronger user intent exists for the normal buffer. -- Scrollback prune: clamp a pinned viewport to the nearest valid line without - changing intent. -- Terminal remount/replay: restore from durable fallback only if the live xterm - instance was actually replaced. - -Do not use xterm `onScroll` to set intent. It may still be useful as a passive -diagnostic signal, but it should not persist authoritative scroll state. - -## Write Contract - -All PTY output that reaches xterm should pass through one wrapper that enforces -intent around the actual `terminal.write` call. - -Suggested owner: - -`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` - -Contract: - -1. Capture intent and current `viewportY` before the write is scheduled. -2. Execute the write through the existing foreground/background/coalescing path. -3. After the write has parsed enough for xterm buffer state to be meaningful: - - If intent is `followOutput`, scroll to bottom. - - If intent is `pinnedViewport`, clamp and restore the saved viewport line. -4. Do not schedule multi-frame retries for normal output. A single post-write - enforcement point should be enough for standard writes. - -For foreground synchronized-output holds, the enforcement point should run after -the held/coalesced frame is released, not for each partial cursor-hide chunk. - -For background writes, preserve `pinnedViewport` if the pane is hidden or -inactive. Hidden output should not silently repin the terminal. - -## Fit And Resize Contract - -`safeFit()` should stop using generic delayed scroll restoration as its normal -behavior. - -New rule: - -1. If dimensions do not change, do nothing. -2. Before fit, capture current intent and viewport. -3. Fit. -4. If intent is `followOutput`, scroll to bottom. -5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the - new `baseY`. - -Avoid fitting hidden or zero-geometry panes. Existing geometry guards should -remain because fitting to transient workspace-switch geometry can send bad sizes -to the PTY and trigger TUI redraw churn. - -## Visibility And Workspace Switch Contract - -Workspace switching should not be a scroll operation. - -On hide: - -- Capture the current intent once. -- Do not run scroll restore. -- Do not update intent from `onScroll`. -- Continue hidden-output throttling/snapshot behavior as today. - -On show: - -- Reattach or refresh renderer resources as needed. -- Flush only the bounded amount of hidden output required for the active pane. -- Apply intent once after the visible output catch-up: - - `followOutput` goes to bottom. - - `pinnedViewport` stays pinned. -- Do not run repeated rAF/timeout scroll restores. - -If the underlying terminal instance was replaced, use durable fallback state. -If the instance stayed alive, its live scroll intent is authoritative. - -## Hidden Output And Snapshot Replay - -Hidden-output snapshot replay is a true fallback path because it clears and -reconstructs xterm content. It needs scroll handling, but it should still obey -intent: - -- Before snapshot replay, capture intent and viewport. -- Replay serialized content. -- Fit only if needed and only with valid visible geometry. -- Reapply the captured intent once: - - `followOutput`: bottom. - - `pinnedViewport`: previous viewport line, clamped. -- Do not let replay set follow-output just because the replayed buffer ends at - bottom. - -If hidden output arrives while the user is pinned, background catch-up should not -move the visible viewport on activation. - -## Split Reparenting - -DOM reparenting can reset browser scroll state and WebGL resources. This is a -legitimate place for a fallback restore, but it should be scoped: - -- Capture intent before reparent. -- Reparent. -- Reattach renderer if required. -- Apply intent once after DOM settles. -- Avoid restoring alternate-screen scrollback, because alternate screen has no - normal scrollback and a TUI owns its cursor. - -The current split-specific rAF/timer code should become a local fallback for DOM -reparenting only, not a general model copied into workspace switching. - -## Performance Constraints - -The design must preserve Orca's terminal performance priorities: - -- Do not keep all hidden terminals hot-rendering. -- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering - work and throttle hidden output as today. -- Avoid per-output layout reads. Intent enforcement should read xterm buffer - fields, not DOM geometry. -- Avoid multi-frame restore loops. They cause visible jitter and keep the - renderer busy after activation. -- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes - can catch up over later frames. -- Do not send resize/SIGWINCH unless dimensions actually changed and the - renderer is the authoritative size owner. - -The intended steady-state cost is one small buffer-state capture per xterm write -batch, not per byte and not per animation frame. - -## Implementation Plan - -1. Add `terminal-scroll-intent.ts`. - - Track `followOutput` vs `pinnedViewport`. - - Provide helpers for user scroll, explicit bottom, write capture, fit - capture, and intent enforcement. - -2. Replace visibility-memory `onScroll` ownership. - - Remove authoritative snapshot updates from `terminal.onScroll`. - - Add capture-phase wheel listeners and keyboard scroll command hooks. - - Keep `onScroll` only for diagnostics if needed. - -3. Route explicit scroll commands through intent helpers. - - `scrollToBottom()` sets `followOutput`. - - `scrollToTop()`, page up, and line up set `pinnedViewport`. - - Downward commands recompute after the command. - -4. Wrap output writes. - - Add an intent-aware writer boundary in the output scheduler or in a narrow - wrapper used by the scheduler. - - Ensure foreground coalescing applies intent after the coalesced frame. - - Ensure background drains preserve pinned viewports. - -5. Convert `safeFit()` to intent-aware fit. - - Capture intent before fit. - - Apply once after fit. - - Remove generic deferred scroll restore from normal fit. - -6. Limit restore fallbacks. - - Keep fallback restore only for true remount/replay/reparent cases. - - Remove workspace-switch rAF/timeout restore loops once intent enforcement is - in place. - -7. Add focused tests. - - User scroll up sets `pinnedViewport`. - - Output while pinned preserves viewport. - - Output while following scrolls to bottom. - - Fit while pinned preserves viewport. - - Fit while following stays at bottom. - - Hidden-output replay while pinned does not repin. - - Workspace hide/show does not change intent. - - `onScroll` does not mutate intent. - -8. Add an E2E reproduction. - - Use the existing `scroll-primary` and `scroll-secondary` worktrees. - - Top pane in `scroll-primary` contains the TUI with enough scrollback. - - Scroll slightly above bottom, switch to `scroll-secondary`, then switch back. - - Assert the terminal remains within a small viewport tolerance and does not - visit top/bottom during the transition. - -## Acceptance Criteria - -- Switching away and back does not move a pinned TUI viewport. -- No visible flash to top or bottom during activation. -- Scrolling all the way to bottom re-enables follow-output and does not later - restore an older pinned position. -- Hidden output in inactive workspaces does not change visible scroll position - until the user explicitly follows output or reaches bottom. -- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts - their cursor or scroll region. -- The active pane remains responsive during heavy hidden-output catch-up. - -## Open Decisions - -- Whether Orca should patch private xterm bottom-scroll internals. The reference - pattern does this for strict ownership, but Orca should first try public API - enforcement at write/fit boundaries and only escalate if xterm continues to - auto-follow independently. -- Whether scroll intent should be stored only by live `Terminal` instance or also - mirrored by leaf ID for remount fallback. The initial implementation should - use live instance state plus a leaf-keyed fallback for true replacement. -- Whether alternate screen needs a separate intent record from normal screen. - The first version can use one record with `bufferType`; a separate normal vs - alternate record is justified only if testing shows mode switches overwrite - user intent. diff --git a/docs/windows-secure-file-acl-hardening.md b/docs/windows-secure-file-acl-hardening.md deleted file mode 100644 index f5036fe1c72..00000000000 --- a/docs/windows-secure-file-acl-hardening.md +++ /dev/null @@ -1,104 +0,0 @@ -# Windows Secure-File ACL Hardening - -## Problem - -Credential files Orca writes on Windows (runtime env auth store, device registry, -e2ee keypair) must end up readable only by the current user, SYSTEM, and -Administrators. On POSIX this is a one-line `chmodSync`, but `writeFileSync`'s -`mode` option is a no-op on Windows, so the NTFS ACL has to be rewritten by -shelling out to PowerShell (`Get-Acl` / `Set-Acl`). PowerShell cold-start is -~1–1.5 s. - -`readEnvironmentStore` calls `hardenExistingSecureFile` on every read. The -env-store parent directory's mtime churns constantly (every secure write updates -it), so an mtime-keyed idempotency cache never matches and a blocking PowerShell -spawn fires on every call. After the remote-runtime tab-sync polling change, the -store is read ~2×/s, turning sporadic mtime misses into a continuous main-thread -storm (~1.8 powershell.exe spawns/sec) that saturates the Electron main thread -and times out `runtimeEnvironments:call`. See #4901 / #5006. - -## Model - -`src/shared/secure-file.ts` applies two different caching + execution strategies, -chosen by whether the target is a directory and whether it is on the write path: - -- **Directories — async + path-cached for the process lifetime.** - A directory's required ACL does not change when its mtime changes, so once a - directory has been hardened in this process it is trusted for the rest of the - process. Directory hardening uses fire-and-forget `execFile` so it never blocks - the main thread. This is the change that kills the #4901 storm. - - _Known limitation:_ a directory that is deleted and recreated mid-process is - not re-hardened until the next restart. The `.orca` secure dirs are not - deleted at runtime, so this is acceptable. - -- **Credential files on the write path — synchronous, cache only on success.** - Because `writeFileSync({ mode })` is a no-op on Windows, a freshly written file - carries the parent directory's inherited (broader) ACL. `writeSecureFile` must - therefore restrict the file's ACL **synchronously** (via `execFileSync`) on the - temp file and on the renamed target before it returns — otherwise the function - would return with the credential briefly readable under inherited ACLs during - the ~1–1.5 s PowerShell cold-start window. The write path is infrequent, so the - synchronous cost is acceptable. The path is cached as hardened **only on - confirmed success**, so a failed apply is retried on the next write. - -- **Existing files on the read path — async + metadata-cached.** - `hardenExistingSecureFile` re-asserts the ACL on an already-existing file at - most once per process (keyed on inode/size/timestamps so post-rename inode - changes are detected). Async is safe here because it only re-asserts an ACL on a - file that already exists; new files are hardened synchronously on the write path - above. Because it fires at most once per file, it does not storm. - -The net effect: the frequent read path never blocks the main thread, while the -infrequent write path closes the async window so credential files are never -published with a broader-than-intended ACL. - -## Requirements - -- Read-path directory and existing-file hardening must not spawn PowerShell more - than once per path per process, regardless of mtime churn. -- `writeSecureFile` must apply the credential file's ACL synchronously before - returning; the file ACL must not be left to a background process. -- A failed synchronous file-ACL apply must not crash the write and must not be - cached as hardened (so it retries). -- No PowerShell is spawned on non-win32 platforms. - -## Manual Windows end-to-end test plan - -The automated e2e harness (`pnpm test:e2e`, Playwright `electron-headless`) runs -on `ubuntu-latest`, where `applySecurePathRestriction` short-circuits to -`chmodSync` and never reaches the PowerShell path. The ACL storm therefore cannot -be reproduced in the cross-platform e2e harness; verify it manually on Windows. - -Pre-req: a Windows client paired to a remote `orca serve` runtime. - -Watcher (PowerShell, run before launching Orca): - -```powershell -while ($true) { - $n = (Get-CimInstance Win32_Process -Filter "Name='powershell.exe'").Count - "{0} powershell.exe count = {1}" -f (Get-Date -Format HH:mm:ss), $n - Start-Sleep -Milliseconds 500 -} -``` - -Steps: - -1. Launch the **stock v1.4.52/v1.4.53** build and open the remote workspace. - - **Before fix:** the watcher oscillates ~1–2 powershell.exe processes/sec - continuously through the load window; the app is unresponsive and the - `[web-session-tabs-sync] … RemoteRuntimeClientError: Timed out` error - appears in the console. -2. Launch the **fixed** build and open the same remote workspace. - - **After fix:** the watcher stays at `0` (no continuous powershell churn); the - env-store directory is hardened at most once; the app loads without the - session-tabs timeout. -3. Write a credential (e.g. sign in / register a device so a secure file is - written), then immediately inspect the file ACL: - - ```powershell - icacls "$env:APPDATA\orca\orca-environments.json" - ``` - - - **Expected:** only the current user, `SYSTEM`, and `Administrators` have - access the instant the write completes (no inherited entries), confirming the - synchronous file-ACL apply closed the async window. diff --git a/docs/workspace-space-scan-resource-bounds.md b/docs/workspace-space-scan-resource-bounds.md deleted file mode 100644 index 81510e3cfea..00000000000 --- a/docs/workspace-space-scan-resource-bounds.md +++ /dev/null @@ -1,238 +0,0 @@ -# Workspace Space Scan Resource Bounds - -## Problem - -Resource Manager scans every non-prunable worktree to calculate workspace disk usage. A fleet with -hundreds of worktrees can make Orca and the host unresponsive while the scan is active. - -Two independent resource failures are possible: - -- macOS and Linux run recursive `du` processes for several worktrees at once. Competing full-tree - metadata walks can saturate local storage and stall unrelated applications. -- Windows, unsupported `du` variants, timeouts, and filesystem errors use a portable recursive - walker. The old walker allocated a promise and retained result node for every entry without a - capacity limit, so a large worktree could exhaust the main or relay process heap. - -The July 27 incident occurred with 298 Orca worktrees. Renderer telemetry remained far below its -heap limit, while quitting Orca immediately restored host responsiveness. The installed binary also -contained the uncapped portable fallback. - -## Root Cause - -`src/main/workspace-space-analysis.ts` admitted up to three worktree scans per repository while -scanning two repositories concurrently. That allowed as many as six local `du` traversals. - -Both the desktop portable walker and `src/relay/workspace-space-scan.ts` recursively called -`Promise.all(entries.map(...))`. Filesystem operation limiters capped active `stat` and `readdir` -calls, but they did not cap queued promises, retained paths, directory arrays, or aggregate result -nodes. A failed `du` scan then repeated the same traversal through this higher-memory path. - -Shared fixed-worker traversal and capacity primitives already exist, but the active desktop and -relay scanners did not use them. - -## Goals - -- Keep the host responsive while scanning hundreds of local worktrees. -- Bound portable traversal memory before admitting filesystem entries. -- Preserve size totals, symlink behavior, top-level compaction, partial failures, cancellation, and - progress reporting. -- Apply equivalent bounds to local, folder-workspace, WSL, and SSH relay paths. -- Preserve the current WSL-aware Git worktree listing and abort signals. - -## Non-Goals - -- Make a 298-worktree scan finish quickly. -- Change Resource Manager UI, progress copy, deletion rules, or scan persistence. -- Exclude `node_modules`, build output, or other directories from reported size. -- Change Git worktree discovery or cleanup behavior. -- Introduce polling, caching, or background automatic scans. - -## Design - -### Local disk admission - -Create one scan-wide limiter for local worktree traversal. Repository discovery and remote scans can -remain concurrent, but every local worktree must acquire the single local slot before invoking -`du` or the portable walker. - -The slot is global to one Resource Manager scan, not per repository. This changes the maximum local -full-tree traversal count from six to one. Cancellation rejects queued admissions and stops the -active child process through the existing `AbortSignal`. - -### Remote fallback admission - -The desktop-side request-by-request SSH fallback runs its traversal inside the desktop main -process, so its budget is charged against Orca's heap rather than the remote host. Repository and -worktree concurrency alone would let six of these traversals hold six independent budgets at once. -A second scan-wide limiter admits at most two, capping aggregate admission at 2 × 64 MiB instead of -6 × 64 MiB. Bulk relay scans stay outside this limiter: their traversal memory lives on the remote -host, one hard capacity per request. - -### Fixed-worker portable traversal - -Use `scanWorkspaceSpaceEntryTree` for desktop local fallback, desktop remote-provider fallback, and -relay fallback scans. The traversal: - -- owns only the configured number of live entry jobs; -- preserves source order and aggregate sizes; -- uses iterative directory frames instead of recursive promise fanout; -- stops on cancellation and removes its abort listener; -- treats unreadable or disappearing entries as partial failures; -- does not follow symlink targets. - -Top-level `du` result processing uses `mapWithConcurrency` so unusually wide workspace roots do not -allocate one live operation per entry. - -### Capacity admission - -Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention: - -- maximum entries in any one directory listing: 100,000; -- maximum estimated live scan state per worktree traversal: 64 MiB. - -The entry cap is per listing rather than per traversal because only a single directory's width is -fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a -listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100 -files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted. -Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share. - -The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object -overhead, and each listing's parent path once. The parent path is charged per listing rather than -per entry because a listing's entries all share a single parent-path string; charging it per entry -multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live -heap and rejected the layouts above once the worktree path passed ~58 characters. These are -admission limits, not post-allocation observations. - -The budget measures what the traversal is holding **right now**, not what it has ever seen. A -directory listing is charged when admitted and released once every one of its entries has been -dispatched, so the caps bound the widest concurrent frontier rather than total tree size. This -distinction decides real workspaces: a cumulative counter charged an ordinary 76,788-entry Orca -worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch -name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state. - -Neither cap may depend on where a user checks out their worktrees, so the estimate charges each -absolute path once per listing rather than once per entry. A live cap depends only on directory -shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries) -and nothing else. - -Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not -fall through into the portable walker, because that would repeat an already rejected traversal. - -### Failure behavior - -- Desktop local scan: return an unavailable worktree row with the capacity error. `classifyError` - maps `WorkspaceSpaceScanCapacityError` to `unavailable` rather than `error`, because the workspace - is intact and readable, just too large to size safely. The Resource Manager renders that row as - "Unavailable" instead of "Failed". -- SSH relay scan: reject the bulk scan; the desktop provider converts it to an unavailable row. -- Generic `du` failure: use the bounded portable fallback. -- Cancellation: propagate the scan-cancelled error rather than converting it to a row failure. -- Missing or unreadable entries below the capacity limit: preserve existing partial-result - behavior. - -### Platform and workspace parity - -- macOS/Linux: one local `du` at a time; bounded Node fallback. -- Windows: one local bounded Node traversal at a time. -- WSL: retain `getLocalProjectWorktreeGitOptions` and its selected distro for worktree discovery; - UNC/native filesystem traversal uses the same local admission and capacity bounds. -- SSH: prefer the bulk relay scan; both the relay and the request-by-request compatibility fallback - use the shared capacity model. -- Folder workspaces: the synthetic main worktree passes through the same local limiter and scanner. - -## Data Flow - -1. Resource Manager starts one deduplicated scan and creates an abort controller. -2. Repository workers list worktrees with existing host-specific Git options and the scan signal. -3. Remote worktrees continue through provider concurrency. Local worktrees wait for the scan-wide - local slot. -4. POSIX local or relay scans try `du`. -5. Top-level entries are admitted against the scan budget and projected with bounded concurrency. -6. If `du` fails generically, the fixed-worker portable traversal scans within the same limits. -7. Capacity failures become unavailable rows; successful results keep existing compaction and - progress behavior. - -## Alternatives Considered - -### Only reduce `du` concurrency - -This protects local storage but leaves Windows, SSH compatibility fallback, and failed `du` scans -capable of unbounded heap growth. - -### Only add traversal capacity limits - -This prevents OOM but still permits several recursive `du` processes to compete for local storage, -which does not address the observed whole-host stall. - -### Remove the portable fallback - -This would fail Windows scans and POSIX environments where `du -d` is unavailable or errors during -active filesystem churn. - -### Skip dependency and build directories - -This would make Resource Manager under-report the directories users most often want to reclaim. - -## Measurement and Performance Budget - -The deterministic regression measurements are: - -- peak simultaneous local `du` calls across repositories: exactly one; -- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two; -- portable traversal live entry jobs: no more than its configured worker count; -- portable entries in any one directory listing: at most 100,000; -- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree - peaks between 4 MiB and 8 MiB; -- progress and final row counts remain unchanged for scans below the limits. - -A follow-up diagnostic span should record strategy (`du` or portable), worktree duration, fallback -reason, admitted entry count, estimated retained bytes, and peak local scan concurrency. It must -record counts and identifiers rather than raw paths or directory trees. - -## Test Plan - -- Desktop integration: force the portable path over its entry budget and assert an unavailable row. -- Relay integration: force the portable path over its entry budget and assert a capacity failure on - both the Windows/portable and POSIX `du` entry points. -- Remote fallback concurrency: hold six SSH fallback traversals across two repositories and assert - no more than two run at once. -- Concurrency: start local worktrees from two repositories, hold the first `du`, and assert the - second does not start until the first completes. -- Shared traversal: verify worker peak, source order, exact-limit success, over-limit failure, deep - trees, partial failures, and cancellation cleanup. -- Existing desktop coverage: local results, symlinks, progress, cancellation, WSL routing, SSH bulk - scans, disconnected SSH, `du` timeout fallback, and IPC deduplication. -- Run `pnpm run typecheck` and `pnpm run build`. - -## Rollout - -1. Connect the shared fixed-worker traversal and capacity budget to desktop and relay scanners. -2. Add the scan-wide single local traversal slot. -3. Add focused capacity and concurrency regression tests. -4. Ship without a migration or feature flag; behavior below the limits is unchanged. -5. Monitor capacity failures and scan duration before considering a higher local concurrency. - -## Risks - -- Scanning hundreds of worktrees takes longer because local work is serialized. The feature already - streams progress and allows users to leave the page, and host responsiveness takes priority over - scan throughput. -- A legitimate worktree above the capacity limit is reported unavailable instead of partially - sized. Failing closed avoids presenting an incomplete size as safe deletion evidence. With a live - cap this now requires a pathological directory rather than merely a large repository. -- Because the local slot is acquired inside the per-repository worker pool, a local worktree waiting - on the slot still occupies one of the six in-flight scan slots. On fleets mixing local and SSH - repositories this can delay remote repositories behind serialized local work, even though remote - scans never contend for the local disk. -- Remote hosts can still process concurrent worktrees, but each relay request has an independent - hard capacity and remote concurrency does not contend with the user's local disk. - -## Validation - -- Focused tests: 36 passed across desktop analysis, IPC, relay, shared traversal, scan budget, and - concurrency primitives. -- TypeScript: all node, CLI, and web projects passed. -- Production build: relay targets, CLI, Electron, renderer, web projection, and macOS native - components completed successfully. -- No live 298-worktree rescan is required for correctness validation; the concurrency and capacity - properties are deterministic tests. diff --git a/docs/worktree-delete-preflight.md b/docs/worktree-delete-preflight.md deleted file mode 100644 index 1b8263762cf..00000000000 --- a/docs/worktree-delete-preflight.md +++ /dev/null @@ -1,78 +0,0 @@ -# Worktree Delete Preflight - -## Problem - -- Local delete paths kill PTYs before git deletion: -- `worktrees:remove` IPC (`src/main/ipc/worktrees.ts`) -- `removeManagedWorktree` runtime/RPC (`src/main/runtime/orca-runtime.ts`) -- On non-force failures (dirty/untracked is common), the worktree stays on disk but terminals are already gone. -- PTY teardown is intentionally destructive and best-effort (`src/main/runtime/worktree-teardown.ts`), so non-force deletability must be checked first. - -## Ground Truth From Code - -- There is no dry-run remove path currently used in git helpers (`src/main/git/worktree.ts`). -- Errors shown to users are normalized through `formatWorktreeRemovalError` (`src/main/ipc/worktree-logic.ts`). -- SSH-backed repos already delegate deletion to provider APIs and should remain provider-owned. -- Current orphan cleanup (`is not a working tree` handling + prune + metadata cleanup) lives in IPC/runtime remove catch blocks and must not regress. - -## Non-goals - -- Do not change force-delete semantics; force may still kill PTYs before git remove. -- Do not add new renderer confirmation states or copy. -- Do not attempt to predict every possible git remove failure. -- Do not change SSH provider teardown ownership. - -## Design - -1. Add local preflight helper in `src/main/git/worktree.ts`. -- Export `assertWorktreeCleanForRemoval(worktreePath: string, force = false): Promise`. -- If `force`, return immediately. -- Run `git status --porcelain --untracked-files=all` in `cwd = worktreePath`. -- If output is non-empty, throw a dedicated error (dirty/untracked). -- If command fails, rethrow original error. - -2. IPC local delete ordering (`worktrees:remove`). -- Keep canonicalization and protected-path validation first (`getRegisteredDeletableWorktree`). -- Keep SSH provider branch unchanged. -- Keep archive hook and symlink cleanup before preflight, so preflight checks the exact post-hook/post-symlink state that `git worktree remove` will see. -- Run preflight. -- If preflight throws an orphan/missing-worktree style error, continue to existing remove path so current orphan cleanup behavior still executes. - Treat at least these as orphan-compatible for preflight: `is not a working tree`, `not a git repository`, and missing-path (`ENOENT`) failures from running status in a removed directory. -- For other preflight failures, throw via `formatWorktreeRemovalError(...)`. -- Only after successful preflight: run `killAllProcessesForWorktree(...)`, then `removeWorktree(...)`. - -3. Runtime/RPC local delete ordering (`removeManagedWorktree`). -- Keep SSH branch unchanged. -- Keep current hook behavior (archive optional via `--run-hooks`, warning when configured but skipped). -- Run preflight after hook handling. -- Preserve orphan compatibility exactly as in IPC: preflight must not short-circuit existing orphan cleanup semantics (including `not a git repository`/`ENOENT` preflight failures that should fall through to the existing remove/catch path). -- On successful preflight: run PTY teardown, then `removeWorktree`. -- Route failures through existing formatted error surface. - -4. Failure-class behavior contract. -- Dirty/untracked (non-force): fail before PTY teardown. -- Preflight subprocess/tooling failures: fail before PTY teardown, formatted. -- Orphan/missing-worktree conditions (`is not a working tree`, `not a git repository`, `ENOENT`): retain current cleanup-and-metadata-removal behavior by running the existing remove/catch flow without PTY teardown. -- Force deletes: no preflight; keep current teardown-before-remove order. - -5. Tests. -- Update ordering assertions for non-force local deletes to `preflight -> kill -> git`. -- Add IPC/runtime tests proving dirty non-force failures happen before any PTY kill. -- Add IPC/runtime tests proving preflight error formatting uses `formatWorktreeRemovalError` path. -- Add IPC/runtime regression tests proving orphan cleanup still runs when preflight encounters orphan-like failures. -- Keep force ordering tests (`kill -> git`). -- Keep SSH tests proving local PTY teardown is not used for SSH-backed repos. - -## Concurrency, Consistency, Limits - -- Preflight narrows, but does not close, the race window: external edits can occur after preflight and before `git worktree remove`. -- Multi-window and out-of-band mutation races remain possible between canonicalization, hooks/symlink cleanup, preflight, kill, and remove. -- IPC has symlink cleanup before preflight; runtime does not. That asymmetry remains unless runtime gains equivalent cleanup. -- Cost is one additional git subprocess per non-force local delete; this is acceptable for a user-initiated destructive action. - -## Rollout - -1. Implement `assertWorktreeCleanForRemoval` + unit tests in `src/main/git/remove-worktree.test.ts`. -2. Wire IPC ordering and failure mapping in `src/main/ipc/worktrees.ts`; update `src/main/ipc/worktrees.test.ts`. -3. Wire runtime ordering and failure mapping in `src/main/runtime/orca-runtime.ts`; update `src/main/runtime/orca-runtime.test.ts`. -4. Run focused tests, then `pnpm typecheck` and `pnpm lint`. diff --git a/docs/worktree-sidebar-drag-autoscroll.md b/docs/worktree-sidebar-drag-autoscroll.md deleted file mode 100644 index 4ae6822c5a8..00000000000 --- a/docs/worktree-sidebar-drag-autoscroll.md +++ /dev/null @@ -1,139 +0,0 @@ -# Worktree Sidebar Drag Autoscroll - -## Problem - -The left sidebar supports reordering worktree rows, but holding a drag near the top or bottom of the scrollable sidebar does not continue scrolling. - -- `src/renderer/src/components/sidebar/WorktreeList.tsx:399` keeps active worktree drag state and cached group rects. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:757` computes the drop from `clientY`, the current sidebar `getBoundingClientRect()`, and `scrollTop`. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1400` updates the custom pointer drag only after pointer events. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1660` uses the native drag path for card drag start/over/drop. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:1920` renders the scroll container as `[data-worktree-sidebar]`. - -## Current Behavior - -The custom pointer path promotes to an active drag after `SIDEBAR_POINTER_DRAG_THRESHOLD_PX`, creates a fixed preview, stores a `WorktreeDragSession`, and schedules `flushWorktreePointerDrag()` on pointer movement. Drop still uses `computeWorktreeDrop()` and `onReorderWorktrees()`. If `computeWorktreeDrop()` returns null, pointer drops may instead pin or move the worktree to a workspace status target. - -The native HTML5 handlers still exist: they store the same `WorktreeDragSession` on `onCardDragStart`, compute the reorder preview in `onDragOver`, commit through `onDrop` or the capture-phase document drop handler, and clear on `onCardDragEnd` through `WorktreeCard`. In the current list rendering, `WorktreeCard` is passed `nativeDragEnabled={false}`, so the active user-facing worktree reorder path is the custom pointer path. If the native handlers remain in place, keep them consistent; workspace status/pin drops are already handled by `useWorkspaceStatusDocumentDrop()`. - -Scroll bookkeeping already exists. `handleScroll()` calls `markScrollMovement()`, which suppresses virtualizer measurement correction. Direct wheel/touch/scrollbar input additionally calls `markDirectScrollInput()`, which blocks anchor restoration retries. Autoscroll must mark programmatic movement, not direct user input. - -## Root Cause - -Both drag paths are event-driven. When the pointer is held at a sidebar edge, no frame loop changes `scrollTop`; without a scroll change, the virtualized list does not reveal farther rows and `computeWorktreeDrop()` keeps evaluating against the same viewport. - -There is also a virtualization constraint: `computeWorktreeDrop()` only considers `worktreeDragSessionRef.current.rects`, which are a snapshot of the source group's mounted rows at drag start. Autoscroll would reveal new rows but still reject drops outside the original snapshot unless the active session refreshes its source-group rects from the current DOM. Refreshing rects must stay limited to the same `sourceGroupKey`; ordering still commits through the existing `worktreeDragUnitGroups`, `getFullDropIndexForWorktreeDragUnit()`, and `onReorderWorktrees()` path. - -## Non-Goals - -- Do not change manual ordering, rank persistence, group membership, lineage expansion, selection, pinning, or workspace status semantics. -- Do not change the workspace board drawer drag behavior. -- Do not add settings, controls, new visual affordances, IPC, persistence, SSH, filesystem, or git-provider behavior. - -## Design - -1. Add a concrete helper for sidebar drag autoscroll, for example `worktree-sidebar-drag-autoscroll.ts`. It should compute a bounded next `scrollTop` from: - - latest pointer `clientX`/`clientY`; - - container bounds; - - `scrollTop`, `scrollHeight`, and `clientHeight`; - - elapsed frame time, clamped so a throttled/background frame cannot jump the list. -2. The helper must return no-op when the pointer is outside the container horizontally, the pointer is outside the vertical edge zones, or the container is already at the relevant scroll bound. -3. Add a small session refresh helper that replaces `worktreeDragSessionRef.current` with the latest `getWorktreeDragRectsForGroup(scrollRef.current, session.sourceGroupKey)` result and revalidates the latest groups: `worktreeDragGroups` must still contain the source group and `session.draggingWorktreeId`, and `worktreeDragUnitGroups` must still contain `session.reorderUnitDraggedIds` for the same `sourceGroupKey`. Do not require every `session.reorderDraggedIds` entry to be in the source group, because the existing multi-select/lineage reorder path filters those ids during commit. If the source group or reordered unit ids disappear, clear the drag instead of committing stale ids. -4. Track autoscroll animation frame ids separately from `WorktreePointerDrag.frameId`. The existing `frameId` is for preview/drop flushing; sharing it would make it easy to cancel the wrong work or skip a needed preview update. -5. Pointer drag path: - - start autoscroll only after `beginWorktreePointerDrag()` promotes the drag to active; - - each autoscroll frame reads the latest `drag.currentX/currentY`; - - apply `container.scrollTop = nextScrollTop` only when it changes; - - call `markScrollMovement()` before or immediately after the scroll write instead of `markDirectScrollInput()`; - - refresh source-group rects from the mounted DOM before recomputing a drop; if the virtualizer has not rendered the newly exposed rows yet, keep the current null-drop behavior for that frame; - - schedule `scheduleWorktreePointerDragFrame(drag)` after a scroll write so the preview line is recomputed against the new `scrollTop` and refreshed rects. -6. Native drag path, if it is kept or re-enabled: - - store the latest native drag-over `clientX/clientY` in a small ref while a `WorktreeDragSession` exists; - - run a separate autoscroll frame loop from `onDragOver`; - - after a scroll write, refresh source-group rects, recompute the native drop preview from the stored `clientY`, and update `worktreeDragState`; - - set `event.dataTransfer.dropEffect = 'move'` only inside real `dragover`/`drop` event handlers when the current drop is valid. A RAF callback cannot update the native drag cursor/effect; it can only update sidebar preview state until the next native drag event. -7. Cleanup must cancel both pointer and native autoscroll loops on drop, document drop, dragend, pointerup, pointercancel, document visibility loss, component unmount, and `clearWorktreeDrag()`. - -Keep the implementation local to the renderer sidebar. The scroll container remains `scrollRef.current`; do not query arbitrary sidebars globally. - -## Data Flow - -Pointer: - -- Pointer down snapshots source-group rects and arms a non-active pointer drag. -- Pointer move beyond the threshold promotes to active, creates the preview, stores the drag session, and starts autoscroll. -- Autoscroll frames update `scrollTop` when the latest pointer point is in an edge zone. -- After each scroll write, the active session refreshes source-group rects from the current DOM and the normal pointer drag frame recomputes the drop preview using the existing `computeWorktreeDrop()`. -- Pointer up commits through the existing `onReorderWorktrees()` path, or through the existing pin/status fallback when no reorder drop is valid. - -Native: - -- If enabled, native drag start stores the drag session and cached source-group rects. -- Native drag over stores the latest point, computes the current drop, and starts autoscroll. -- Autoscroll frames reuse the latest stored point to update scroll, refresh source-group rects, and recompute preview. They do not try to set `DataTransfer.dropEffect`. -- Native drop/document drop commits through the existing reorder path; dragend clears state. - -## Edge Cases - -- Top and bottom bounds: do not keep writing the same `scrollTop`. -- Horizontal outside: no sidebar autoscroll if `clientX` is left or right of the scroll container. -- Vertical outside: allow scrolling when `clientY` is at or slightly beyond the top/bottom edge, but cap speed. -- Threshold: custom pointer autoscroll must not start before the existing drag threshold. -- Native event sparsity: continue from the last known drag-over point while events pause, but stop on dragend/drop/visibility loss. -- Drop validity while scrolling: if refreshed source-group rects still do not cover the pointer after scroll, preserve the current null-drop behavior rather than guessing a new index. -- Virtualized rows: rows can mount/unmount while scrolling. Refresh rects from `[data-worktree-drag-id]` for the source group only, and tolerate a frame where the virtualizer has not mounted newly visible rows yet. -- Source mutations: if the dragged worktree, source group, or reorder unit disappears during drag, clear the drag instead of committing against stale ids. -- Concurrent ordering changes: final drop should use the latest `worktreeDragGroups`/`worktreeDragUnitGroups` already captured by React callbacks, but the source group and reordered unit ids must be revalidated before commit. Do not rederive a different dragged set mid-drag. -- Multi-window/external mutations: this is renderer-local UI state. Do not persist autoscroll state or broadcast it; external list mutations should only invalidate or clear the active drag. -- Workspace status and pin targets: preserve pointer fallback behavior and do not make native reorder autoscroll steal document-level status/pin drops. The capture-phase document reorder handler should only prevent default/stop propagation when `computeWorktreeDrop()` returns a valid reorder drop. -- Unmount/remount: cancel animation frames and remove previews/styles in the existing cleanup path. - -## Test Plan - -- Unit: add tests for the autoscroll calculation helper covering top edge, bottom edge, middle no-op, horizontal outside, scroll bounds, capped speed, and elapsed-time scaling. -- Unit: add tests for the rect/session refresh helper with same-group refresh, missing source group, missing dragged ids, and empty mounted rects. -- Unit: add tests for any extracted native/pointer loop coordinator only if it can run without a full virtualized React render. -- Unit: keep `worktree-manual-order.test.ts` and `worktree-drag-units.test.ts` passing to prove rank and drag-unit semantics did not change. -- Typecheck/lint: run `pnpm run typecheck` and `pnpm run lint`. -- Focused tests: run `pnpm test -- src/renderer/src/components/sidebar/worktree-manual-order.test.ts src/renderer/src/components/sidebar/worktree-drag-units.test.ts` plus the new autoscroll helper tests. -- Electron/manual: validate a long worktree list by dragging near the bottom until the list scrolls, dropping, then dragging near the top until it scrolls back. Also smoke native drag if it is reachable in the current app configuration. - -## UI Quality Bar - -Follow `docs/STYLEGUIDE.md` and the existing sidebar tokens. This change should not introduce new colors, shadows, controls, or copy. The only visible behavior change is smooth scrolling while dragging near the sidebar edge. The existing floating preview, insertion line, row opacity, status/pin highlights, focus ring, and sidebar scrollbar styling should remain unchanged. - -No clipping, flicker, stuck insertion line, unexpected row jump, or preview lag should appear during autoscroll. - -## Review Screenshots - -Required evidence: - -1. Sidebar precondition with enough worktrees to scroll. -2. Pointer drag held near the bottom edge after autoscroll advances the list, with preview and insertion line visible. -3. Pointer drag held near the top edge after autoscroll moves back upward, with preview and insertion line visible. -4. Final dropped order visible in the sidebar. - -Optional evidence if native drag is enabled in the tested build: - -5. Native drag autoscroll in progress or a note explaining why native drag could not be exercised. - -Do not commit evidence images. - -## Rollout - -1. Add `worktree-sidebar-drag-autoscroll.ts` and focused unit tests. -2. Add session rect refresh/revalidation and use it before drag preview/drop recomputation during active autoscroll. -3. Wire custom pointer-drag autoscroll with separate frame cleanup. -4. If the native handlers remain supported or are re-enabled, wire native drag-over autoscroll with latest-point storage and document dragend/drop cleanup, keeping `dropEffect` writes inside drag events. -5. Run typecheck, lint, focused sidebar tests, and Electron validation with screenshots. - -## Lightweight Eng Review - -- Scope: correctly limited to the worktree sidebar drag paths. The plan must not touch persistence, ordering algorithms, workspace board drawer drag, SSH, filesystem, or git-provider code. -- Architecture/data flow: renderer-only helper is appropriate. Pointer and native drag should share the scroll calculation and rect refresh/revalidation, not necessarily the same animation-frame state, because their preview/drop update paths differ. -- Failure modes: frame leaks, stale last drag-over points, redundant bound writes, stale session ids after external mutations, virtualization gaps, document-level status/pin drops, invalid `dropEffect` assumptions, and unmount cleanup are in scope. -- Tests: pure scroll helper and rect-refresh tests are required. Full virtualized pointer behavior is not a good jsdom target; use focused unit tests plus Electron/manual validation. Existing manual-order tests are regression tests only, not evidence that autoscroll works. -- Performance/blast radius: one RAF loop per active drag path is acceptable only while a drag session exists. Avoid layout thrash by reading container rect once per frame before writing `scrollTop`, and avoid React state writes when the recomputed preview is unchanged. -- UI quality: no new design surface. Verify smoothness, insertion-line accuracy, existing preview styling, and absence of row jumps against the style guide. -- Screenshot requirement: screenshots are required for review evidence but must not be committed. -- Residual risks: native drag event behavior differs across Electron/platforms, and virtualized rows may lag one frame behind programmatic scroll writes. Validate the main pointer path first and explicitly document any native limitation found during implementation. diff --git a/docs/wrapped-terminal-file-link-fragments.md b/docs/wrapped-terminal-file-link-fragments.md deleted file mode 100644 index ac1e68ec54a..00000000000 --- a/docs/wrapped-terminal-file-link-fragments.md +++ /dev/null @@ -1,115 +0,0 @@ -# Wrapped Terminal File-Link Fragments - -## Problem - -A file path hard-wrapped between terminal rows is not clickable when the continuation row also contains sibling content. In the reported three-link line, the middle path ends the first row and continues at the start of the second, while the first and third paths remain clickable. - -The provider builds hard-wrap candidates in `wrapped-terminal-link-ranges.ts:172-224`, and both hover and direct modifier-click consume them through `terminal-link-handlers.ts:106-135` and `terminal-file-link-hit-testing.ts:100-109`. - -## Root cause - -`buildHardWrappedPathLogicalLineCandidates` trims and joins whole physical rows. A continuation row is accepted only when the entire trimmed row is a path fragment (`wrapped-terminal-link-ranges.ts:195-203`). A row such as `transparent-...png · validation-screenshots/03-after-light-theme.png` therefore stops reconstruction. Orca probes the two incomplete middle fragments separately, rejects both as nonexistent, and retains only the complete first and third paths. - -## Non-goals - -- Changing file-path parsing, filesystem existence semantics, tooltip copy, or open routing. -- Joining arbitrary prose, spaced paths, or multiple sibling links into one path. -- Relying on xterm soft-wrap metadata for output that was hard-wrapped by an agent or TUI. -- Adding a new IPC method, bypassing the existing existence cache, or changing local/SSH/runtime routing. - -## Design - -1. Add a focused regression using the exact three-link/two-row shape. Make the existence stub return true only for the three complete paths, then assert that provider calls for either physical row return the same middle link and exact multi-row range. Assert that no candidate/link spans either `·` separator. -2. Reuse the existing conservative hard-wrap fragment alphabet. From a possible first row, slice its maximal fragment suffix; append zero or more continuation rows only while their whole trimmed text is a fragment; then slice the maximal fragment prefix from the first mixed-content row and stop. The only suffixes accepted without a path-name character are an exact POSIX root (`/`), one backslash for the first half of a UNC root, a bare ASCII drive prefix such as `C:`, and the complete relative prefixes `./`, `../`, and `~/`. A boundary candidate is emitted only when it covers the requested row, has at least two non-empty row fragments, and the fully joined text passes the existing path-start predicate. It may end at the first proper prefix slice of a mixed row or at the last available whole-fragment row; the latter is limited to whitelisted incomplete starts and is skipped when whole-row reconstruction already emitted the same text. Existing whole-row candidates remain responsible for ordinary/deep hard wraps, including a mixed starting row followed only by whole-fragment rows. -3. Slice `columns` with each fragment so ranges retain the original xterm cells. Build the async-staleness fingerprint from each source row's full translated text and metadata as well as the selected slice; changing a sibling token must invalidate an in-flight result even if the reconstructed path is unchanged. -4. Generate at most one boundary candidate per scanned start row—never every suffix/prefix combination. Preserve the existing bounds of 20 possible start rows and 20 rows per candidate, logical-line deduplication, and longest-non-overlapping-link selection. The existing builder can emit up to 210 whole-row candidates in its all-fragment worst case; this change may add at most 20 boundary candidates, not another quadratic set. -5. Keep existence validation in the provider's current local/SSH/runtime path and cache. The valid reconstructed path necessarily adds its desired existence lookup compared with the broken behavior; do not add probes for arbitrary suffix/prefix combinations or change existing overlap/cache behavior in this focused fix. -6. Verify direct modifier-click fallback from both halves. This path shares the candidate builder but remains synchronous and uses its existing cache/known-root preference before `openDetectedFilePath` performs normal routing checks. - -## Data flow - -- xterm buffer row under hover/click -- bounded hard-wrap start/candidate windows and conservative endpoint slicing -- whole-row candidates plus at most one boundary candidate per start row -- existing terminal file-link parser -- existing local/SSH/runtime path resolution and existence cache -- mapped multi-row xterm range -- hover tooltip or modifier-click open - -## Edge cases - -- A row boundary immediately after POSIX `/`, drive prefix `C:`, the first `\` of a UNC path, or complete `./`, `../`, and `~/` prefixes must reconstruct the complete path. Other punctuation-only suffixes and bare prose tokens remain ineligible, and the joined text must independently satisfy the full path-start predicate. -- A continuation prefix may end before a separator (`·`), prose, or a sibling path; none of that suffix may enter the reconstructed candidate. -- A starting suffix may begin after prose or a sibling path; its original xterm column must be retained. -- Rows containing only one path fragment must keep the existing deep (up to 20 rows) reconstruction behavior. -- Soft-wrapped rows, Unicode/multi-code-unit column mappings, known worktree roots, and spaced paths must remain unchanged. In particular, this change does not broaden fragment extraction to whitespace-containing paths. -- Full-row source fingerprints must reject stale async results when text inside or outside the selected fragment changes. -- Provider calls for remote paths must still use the owning pane's runtime environment or SSH connection and the connection-scoped existence-cache key; fragment extraction itself must not assume a local filesystem. -- Incomplete fragment combinations remain filtered by the existing filesystem existence check. - -## Test plan - -- Candidate/range unit: cover the exact suffix/prefix slices, original xterm columns, full-row fingerprint changes, at most one added boundary candidate per start row, and no candidate spanning a sibling separator. -- Provider integration: add the exact reported three-link regression to `terminal-link-handlers.test.ts`; across provider calls for both physical rows, assert all three complete links, the same middle range from each call, no incomplete or giant merged link, and that the complete middle path reaches the normal existence check. -- Direct click: exercise hit-testing on the first and second physical halves of the middle path and assert both route the same complete path. -- Compatibility: cover a backslash/drive-letter wrapped path and a remote-runtime or SSH existence call, proving the reconstructed path keeps the owning connection/environment. -- Regression: run `wrapped-terminal-link-ranges.test.ts`, `terminal-link-handlers.test.ts`, and terminal-link parser tests. -- Static: run formatter/check, web typecheck, lint, max-lines ratchet, and relevant repository checks. -- Electron: render the exact text at the reproduced 133-column terminal width; verify pointer/tooltip and modifier-click from both middle fragments, then smoke-test the first and third links. - -## UI quality bar - -No visual styling changes. The exact same terminal text and layout must render without overlap, clipping, or altered wrapping. The only visible behavior change is that both physical halves of the middle path show the same pointer affordance and tooltip and activate the same file, consistent with the first and third links and `docs/STYLEGUIDE.md` interaction guidance. - -## Review screenshots - -1. Before, on the base revision: full Electron window hovering the broken middle path at the reproduced width (no tooltip/link affordance). -2. After: full Electron window hovering the first physical half of the middle path, with tooltip visible. -3. After: full Electron window hovering the continuation half, with the same tooltip/path visible. -4. After adjacent-feature smoke: full Electron window hovering the first or third complete sibling link. - -## Rollout - -1. Add the failing exact-shape regression and range/click assertions. -2. Implement boundary-fragment candidate extraction and cell mapping. -3. Run focused tests and static checks. -4. Validate the exact scenario in Electron and capture screenshots. -5. Open an unmerged PR. - -## Lightweight Eng Review - -- Scope: limited to hard-wrapped path candidate reconstruction; parser, routing, cache, and UI styling stay unchanged. -- Architecture/data flow: the shared candidate builder remains the single boundary for hover and click behavior, so local, daemon, SSH, and remote runtime flows receive identical ranges before their existing existence checks. -- Failure modes covered: - - sibling links accidentally merged into one spaced path - - only one physical half hit-tests - - incorrect xterm columns after slicing - - Windows separators rejected - - deep single-fragment rows regress - - extra remote/local existence probes on hover -- Test coverage required: - - exact three-link provider regression from both hovered rows - - exact range boundary assertions - - direct click from both halves - - existing deep-wrap, Unicode, stale-result, parser, SSH/runtime tests -- Performance/blast radius: preserve the current 20-start-row/20-rows-per-candidate bounds (up to 210 existing whole-row candidates). Boundary extraction is linear per examined row and adds at most 20 candidates, only where a mixed continuation stops whole-row reconstruction. Resolving the previously missing complete path adds the intended cached existence check; the change adds no IPC method and does not alter local/remote routing or cache keys. -- UI quality bar: unchanged rendering and style; consistent pointer, tooltip, and activation across both middle fragments, checked in the real Electron terminal against the style guide. -- Required review screenshots: - 1. exact three-link full-window baseline - 2. middle first-half hover/tooltip - 3. middle continuation-half hover/tooltip - 4. first/third sibling hover smoke -- Residual risks: local macOS is the available live Electron environment; Windows separator and SSH/runtime behavior require automated coverage and shared-code review. - -## Terminal Reliability Proof - -- Reliability class: `terminal-link.path-boundary-reconstruction`; the broader manifest entry `xterm-addon.boundary-containment` is related but does not register file-link correctness, so this remains an explicit accepted manifest gap rather than changing that gate's scope in a bug fix. -- Product change type: renderer runtime hardening with deterministic regression coverage. -- Invariant: one logical hard-wrapped file path maps to the same original xterm cells and owning local/SSH/runtime context from either physical row, without absorbing sibling text. -- Failure source: the reproduced three-link line where only the first and third links were clickable. -- Oracle: both provider row calls return the same complete middle path/range; direct hit-testing on either half opens that path; root-, drive-, and UNC-boundary candidates retain their exact xterm ranges; no emitted boundary candidate contains `·`. -- Provider/platform matrix: local and SSH provider behavior covered; Windows separators/cell mapping covered; daemon and remote-runtime use the same builder and routing but are not live-tested; Linux, Windows, WSL, mobile/relay are accepted live-validation gaps. -- Performance budget: the scan stays capped at 20 starts and 20 rows per candidate, emits at most one boundary candidate per start, and rejects non-path starts before reading possible continuation rows. No polling, timers, listeners, subprocesses, or new IPC methods are added; only the newly valid path reaches the existing cached existence probe. -- Diagnostics: the full source-row fingerprint rejects stale async results; deterministic range/provider tests are the regression breadcrumb. No new product telemetry or raw terminal logging is warranted. -- Gate status: no manifest entry added or promoted. Revisit only if this parser grows beyond bounded local row reconstruction or the regression recurs outside the covered provider/platform matrix. -- Rollback/demotion rule: revert boundary-fragment reconstruction if Electron shows sibling-path merging, incorrect hit regions, or material hover latency; keep the exact red regression as the behavioral oracle. diff --git a/docs/wsl-osc7-sleep-wake-cwd.md b/docs/wsl-osc7-sleep-wake-cwd.md deleted file mode 100644 index 24d46b70660..00000000000 --- a/docs/wsl-osc7-sleep-wake-cwd.md +++ /dev/null @@ -1,153 +0,0 @@ -# WSL OSC 7 Sleep/Wake CWD - -## Problem - -On native Windows, a WSL terminal can persist a fake UNC current working directory and then fail to wake. The deterministic trigger is a WSL shell emitting `file:///home/...` before sleep: - -- `osc7-file-uri.ts` treats every non-local authority as a UNC server when parsing Win32 paths. -- `Session` constructs its daemon `HeadlessEmulator` without the WSL execution context, so checkpoints can store `\\\home\...`. -- `OrcaRuntimeService` likewise gives every local Windows PTY Win32 OSC 7 semantics; `remotePosixAuthority` only protects POSIX SSH PTYs. -- `HistoryReader.restoreFromIncrementalLog` creates a third context-free emulator. A log containing the OSC 7 can therefore re-create the bad CWD even if the base checkpoint is valid. -- `DaemonPtyAdapter.doSpawn` prefers `restoreInfo.cwd` over the requested worktree CWD. `pty-subprocess.ts` later validates the fake UNC and rejects it before spawning WSL. - -Reproduction on Windows 11 build 26200, WSL 2.3.26.0, Ubuntu 24.04.1 LTS, and Orca 1.4.144-rc.4: - -1. Open a terminal in `\\wsl.localhost\Ubuntu\home\\...`. -2. Emit `OSC 7;file:///home//...`. -3. Sleep for 38-69 seconds, then wake. - -Both triggered attempts failed. The saved CWD became `\\\home\\...`; the original WSL UNC remained valid. A 60-second control sleep without the hostname OSC 7 passed. - -## Root cause and invariant - -OSC 7 authority semantics follow the PTY's execution environment, not Electron's host OS. A native Windows shell may legitimately mean `\\server\share` by `file://server/share`; a WSL shell means a POSIX pathname inside its already-known distro. Orca currently classifies local WSL PTYs as generic Win32 PTYs. - -The fix must establish one immutable `wslDistro: string | null` per PTY incarnation before any OSC 7 bytes or recovered history are parsed. The same value must drive daemon live parsing, incremental-history replay, runtime parsing, and legacy CWD recovery. URI authority must never select a distro. - -Resolve that value with the same precedence already used to launch WSL: - -1. distro in an explicit WSL UNC `cwd`; -2. distro in the worktree encoded by the daemon session ID; -3. trimmed `terminalWindowsWslDistro` selected for the spawn. - -Centralize this resolution with the existing WSL session-context code so the parser and subprocess cannot disagree. Do not infer local WSL context for `connectionId`/SSH PTYs. - -## Non-goals - -- Do not change native Windows UNC, POSIX SSH, or remote Windows drive-path semantics. -- Do not redesign terminal history, sleep, renderer link routing, or PTY identity. -- Do not probe WSL, DNS, the filesystem, or the network on the output path. -- Do not infer a distro from an OSC hostname or mutable global/default-distro state. - -## Design - -### 1. Pure conversion and parser context - -Move `toWindowsWslPath` to `src/shared/wsl-paths.ts` and re-export it from `src/main/wsl.ts` for existing callers. Preserve its current rules: lowercase `/mnt/` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\`. `/MNT` and `/mnt/C` are case-sensitive Linux paths, not drvfs aliases. - -Add `wslDistro?: string` to `ParseFileUriPathOptions`. When present, `parseFileUriPathParts` must: - -- decode `url.pathname` exactly once; -- construct the path with `toWindowsWslPath(decodedPath, wslDistro)`, regardless of URI authority; -- continue returning the normalized URI hostname as metadata; -- reject malformed URLs/percent encoding as today and leave the prior CWD unchanged. - -Without `wslDistro`, retain every existing `pathFlavor` and `remotePosixAuthority` branch. - -Thread the option through `TerminalOscCwdTitleScanner` and `HeadlessEmulator`. Scanner options are constructor-scoped, so split sequences retain the same context without global state. - -### 2. Daemon and history ownership - -`TerminalHost` resolves the immutable distro before spawning. Pass it both to `createPtySubprocess` and to a new `SessionOptions.wslDistro`; `Session` passes it to its `HeadlessEmulator`. Store the resolved value on `Session` and return it on create/attach so a later window observes the live session's context instead of reinterpreting it from current settings. Make the response field additive/optional for compatibility with an older preserved daemon. - -Extend `HistoryReader.detectColdRestore` with optional parser context and use it for the scratch `HeadlessEmulator` in `restoreFromIncrementalLog`. Every detect path in `DaemonPtyAdapter`--initial detection, probe/create race recovery, and failed history seeding--must pass the same resolved distro. This is required; fixing only the live `Session` leaves incremental restore able to reproduce the bug. - -### 3. Narrow legacy checkpoint recovery - -Immediately after each cold-restore detection, normalize `restoreInfo.cwd` before it becomes `effectiveCwd` or `coldRestore.cwd`. Recovery runs only on native Windows with a resolved local WSL distro: - -1. Preserve an absolute drive path. -2. Preserve a `\\wsl.localhost\...` or `\\wsl$\...` path only when its distro matches the resolved distro case-insensitively. -3. Convert an absolute POSIX path through the resolved distro. -4. Repair the known legacy shape only when the UNC server equals `os.hostname()` case-insensitively: strip the server, interpret the share and tail as the Linux absolute path, then convert it. Thus `\\HOST\mnt\c\x` becomes `C:\x`. -5. For a mismatched-distro WSL UNC, another UNC server, a relative path, or malformed input, discard the recovered CWD and fall back to the current requested CWD (or the normal spawn default if none was supplied). Do not guess. - -Apply the corrected/fallback value to both subprocess creation and the returned cold-restore payload. This keeps runtime seeding, the sticky restore cache, `initialCwds`, and the next checkpoint consistent. The repair is idempotent and never mutates history files in place. - -### 4. Runtime ownership and races - -Add `wslDistro: string | null` to `RuntimePtyWorktreeRecord`; a boolean `isWsl` is insufficient for multi-distro parsing. Pass the resolved distro from the spawn result into runtime registration. For reconstructed local records, a WSL UNC worktree may supply a fallback distro via `parseWslUncPath`; never do this for SSH records. - -Daemon PTYs can emit output before `provider.spawn` resolves, and cold-restore seeding currently runs before `registerPty`. Register the expected daemon session's execution context before spawn, then replace it with the daemon-returned immutable value before snapshot/cold-restore seeding. If late discovery changes a context after a runtime emulator was created, discard that emulator and re-seed it from the authoritative provider snapshot; never keep a buffer whose CWD was parsed under mixed contexts. - -Clear execution context with the other per-PTY parser maps on exit, pruning, failed spawn, and provider-generation reset. Reusing a PTY ID must not inherit a prior distro. - -## Data flow - -```text -spawn cwd/session/preference - -> resolve one immutable local WSL distro - -> daemon Session scanner + HistoryReader replay scanner - -> decoded OSC 7 pathname (authority retained only as metadata) - -> toWindowsWslPath(pathname, distro) - -> daemon checkpoint + runtime live/headless CWD - -> sleep/wake with a valid WSL UNC or drive CWD -``` - -Legacy recovery is a boundary repair: - -```text -WSL cold restore + old checkpoint CWD - -> exact allowlist normalization - -> corrected spawn and coldRestore payload - -> next checkpoint naturally persists the corrected live CWD -``` - -## Consistency and failure modes - -- Context is per PTY incarnation, not global, so simultaneous Ubuntu and Debian panes cannot contaminate each other. -- The first creator owns a daemon session's immutable context. Concurrent/multi-window attaches consume the stored value; changed settings do not mutate a live session. -- A distro mismatch in externally changed history falls back to the requested worktree CWD instead of launching the wrong distro. -- Atomic checkpoint replacement remains unchanged. Concurrent restore callers may read different complete generations, but each normalizes before spawn and daemon create/attach still selects one live session. -- Missing context deliberately preserves current behavior. Tests must cover every local WSL construction path so this fallback cannot silently remain on the affected path. -- Native UNC and SSH behavior remain isolated because neither receives local WSL context. -- No filesystem/network work is added per output chunk; parsing remains bounded string work on completed OSC sequences. - -## Test plan - -- `osc7-file-uri.test.ts` and shared WSL-path tests: hostname/localhost/empty WSL authorities; hostname metadata; `/home`, `/`, lowercase `/mnt/c`, `/MNT`, `/mnt/C`; spaces/percent decoding; invalid encoding; native UNC; POSIX SSH; Windows SSH drive paths. -- `headless-emulator.test.ts`: WSL context propagation across ordinary and split OSC sequences; two emulators with different distros. -- `terminal-host.test.ts`/`session.test.ts`: resolved context reaches the daemon emulator; attach returns the session's stored context and does not adopt a conflicting later preference. -- `history-reader.test.ts`: incremental-log replay containing the exact hostname OSC 7 yields the correct WSL CWD. -- `daemon-pty-adapter.test.ts`: cover every `detectColdRestore` branch plus sticky-cache output. Repair hostname UNC and POSIX CWDs; preserve matching WSL UNC and drive paths; reject mismatched distro, other UNC, relative, native, and SSH/non-WSL cases. Assert both create/attach CWD and `coldRestore.cwd`. -- `orca-runtime.test.ts` and IPC PTY tests: context exists before early daemon output and before headless seeding; Windows-host worktree with a selected WSL distro; WSL UNC fallback after reconstruction; attach correction; PTY-ID reuse; simultaneous distros; local native and SSH isolation. -- Run focused tests, then `pnpm typecheck` and `pnpm lint`. -- Electron on native Windows: in a real Ubuntu WSL pane, emit the exact hostname OSC 7, sleep/wake twice, run `pwd` after each wake, and verify no `DaemonProtocolError`. Native UNC semantics are covered by deterministic unit tests; a screenshot of an arbitrary UNC string is not meaningful validation. - -## UI quality and review evidence - -There is no UI, layout, copy, or interaction change, so the Stage 5 visual-quality loop is skipped. Terminal content, CWD, and adjacent native/SSH behavior must remain unchanged. - -Electron validation still requires three evidence screenshots for user review: - -1. Before sleep: the WSL terminal shows the printed hostname/emission command and `pwd`. -2. After the first wake: the same pane shows the same `pwd` and no wake error. -3. After the second wake: the same pane again shows the same `pwd` and no wake error. - -## Lightweight engineering review - -- **Scope:** Parser context, immutable context propagation, history replay, and narrow legacy recovery. Runtime/IPC fields are necessary because parsing begins outside the daemon and may precede spawn completion. -- **Architecture/data flow:** One resolved distro drives every parser for one PTY incarnation. The URI hostname remains metadata; it never selects path namespace or distro. -- **Failure modes:** Covers missing/stale context, incremental replay, probe/create races, sticky restore, mismatched external history, split chunks, concurrent attaches, multi-window reuse, multi-distro isolation, PTY-ID reuse, and SSH/native boundaries. -- **Tests:** Requires parser tables, propagation tests at every emulator construction site, all adapter restore branches, runtime early-byte/seed races, and the deterministic Windows Electron reproduction twice. -- **Performance/blast radius:** One optional string per PTY/session and bounded conversion per OSC 7; no probes or new hot-path I/O. Non-WSL behavior is unchanged when the option is absent. -- **UI/screenshots:** No design-review loop. Three Electron screenshots are required as functional evidence; native UNC stays an automated regression test. -- **Residual risk:** Legacy checkpoints created from a non-machine hostname cannot be distinguished safely from a real UNC and therefore fall back to the requested CWD. A renamed/uninstalled distro still fails normally; this change does not guess a replacement. - -## Rollout - -1. Centralize distro resolution and WSL path conversion. -2. Make the parser, daemon Session, and history replay distro-aware. -3. Add allowlisted legacy recovery and keep spawn/cold-restore metadata consistent. -4. Make runtime context available before output/seeding and reset it per incarnation. -5. Add regressions, run static checks, then validate two Windows sleep/wake cycles with review screenshots. From 340faaa839921c85848a2dc1f27240ebbf8dc826 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:40:47 -0700 Subject: [PATCH 120/123] fix(workspaces): use the emojibase shortcode preset for emoji suggestions (#11888) Swap the worktree-name emoji picker from emojibase-data's `github` shortcode preset to `emojibase`, which carries both `flag_kr` and `south_korea` style flag names, and drop the hand-maintained `kr` entry that patched around the gap. Filter skin-tone aliases so they neither crowd the suggestion list nor clobber base-emoji branch names. Search now matches anywhere in the shortcode, ranked exact > prefix > word-start > substring, so `:korea` surfaces both Koreas. Emoji-derived branch names now prefer spelled-out aliases: flags use country names (japan, germany, south-korea) and cryptic stubs are skipped (thumbsdown over no, victory over v). --- src/main/ipc/worktree-logic.test.ts | 6 ++- .../lib/workspace-emoji-shortcodes.test.ts | 31 ++++++++++++-- .../src/lib/workspace-emoji-shortcodes.ts | 34 ++++++++------- src/shared/emoji-shortcode-catalog.ts | 42 ++++++++++++++----- tests/e2e/worktree.spec.ts | 8 ++-- 5 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 5a29189c9b8..ae76b28da0c 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -77,7 +77,8 @@ describe('sanitizeWorktreeName', () => { it('uses readable git-safe shortcodes for known emoji', () => { expect(sanitizeWorktreeName('🚀')).toBe('rocket') expect(sanitizeWorktreeName('👩‍💻✨')).toBe('woman-technologist-sparkles') - expect(sanitizeWorktreeName('🇯🇵')).toBe('jp') + expect(sanitizeWorktreeName('🇯🇵')).toBe('japan') + expect(sanitizeWorktreeName('👎')).toBe('thumbsdown') expect(sanitizeWorktreeName('1️⃣')).toBe('one') }) @@ -86,7 +87,8 @@ describe('sanitizeWorktreeName', () => { }) it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => { - expect(sanitizeWorktreeName('\u{1fae9}')).toBe('workspace') + // Unassigned in Unicode 17, so no emojibase shortcode can cover it yet. + expect(sanitizeWorktreeName('\u{1faeb}')).toBe('workspace') }) it('does not treat arbitrary punctuation as a workspace name', () => { diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts index 83663794307..0f67df13987 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts @@ -11,8 +11,14 @@ describe('workspace emoji shortcodes', () => { expect(searchWorkspaceEmojiShortcodes('wink', 1)).toEqual([{ emoji: '😉', shortcode: 'wink' }]) }) - it('finds the Korean flag by kr shortcode', () => { - expect(searchWorkspaceEmojiShortcodes('kr', 1)).toEqual([{ emoji: '🇰🇷', shortcode: 'kr' }]) + it('finds flags by country name and by ISO code fragment', () => { + expect(searchWorkspaceEmojiShortcodes('south_korea', 1)).toEqual([ + { emoji: '🇰🇷', shortcode: 'south_korea' } + ]) + expect(searchWorkspaceEmojiShortcodes('kr', 1)).toEqual([{ emoji: '🇰🇷', shortcode: 'flag_kr' }]) + expect(searchWorkspaceEmojiShortcodes('germany', 1)).toEqual([ + { emoji: '🇩🇪', shortcode: 'germany' } + ]) }) it('ranks an exact shortcode before longer aliases', () => { @@ -21,6 +27,23 @@ describe('workspace emoji shortcodes', () => { }) }) + it('matches inside a shortcode, ranking word starts above incidental substrings', () => { + expect(searchWorkspaceEmojiShortcodes('korea', 2)).toEqual([ + { emoji: '🇰🇵', shortcode: 'north_korea' }, + { emoji: '🇰🇷', shortcode: 'south_korea' } + ]) + expect( + searchWorkspaceEmojiShortcodes('kr', 3).map((suggestion) => suggestion.shortcode) + ).toEqual(['flag_kr', 'ukraine', 'cockroach']) + }) + + it('omits skin-tone aliases from suggestions', () => { + expect(searchWorkspaceEmojiShortcodes('wave')).toEqual([ + { emoji: '👋', shortcode: 'wave' }, + { emoji: '🌊', shortcode: 'water_wave' } + ]) + }) + it('deduplicates aliases that resolve to the same emoji', () => { const suggestions = searchWorkspaceEmojiShortcodes('wink') expect(new Set(suggestions.map((suggestion) => suggestion.emoji)).size).toBe(suggestions.length) @@ -43,8 +66,8 @@ describe('workspace emoji shortcodes', () => { }) }) - it('replaces the completed kr shortcode', () => { - expect(replaceCompletedWorkspaceEmojiShortcode(':kr:', 4)).toEqual({ + it('replaces a completed flag shortcode', () => { + expect(replaceCompletedWorkspaceEmojiShortcode(':flag_kr:', 9)).toEqual({ value: '🇰🇷', cursor: 4 }) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index a75da33d2c1..3a5efc7e8d1 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -16,19 +16,24 @@ export type WorkspaceEmojiReplacement = { value: string } -const WORKSPACE_EMOJI_SHORTCODE_ADDITIONS: readonly WorkspaceEmojiSuggestion[] = [ - { emoji: '🇰🇷', shortcode: 'kr' } -] - -const SHORTCODE_ENTRIES = [ - ...STANDARD_EMOJI_SHORTCODE_ENTRIES, - ...WORKSPACE_EMOJI_SHORTCODE_ADDITIONS -] - const EXACT_SHORTCODE = new Map( - SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) + STANDARD_EMOJI_SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) ) +// Lower tiers rank first, so `korea` surfaces `south_korea` above `dishwasher`-style incidental hits. +const MATCH_TIER = { exact: 0, prefix: 1, wordStart: 2, substring: 3 } as const + +function matchTier(shortcode: string, query: string): number | null { + const index = shortcode.indexOf(query) + if (index < 0) { + return null + } + if (index === 0) { + return shortcode.length === query.length ? MATCH_TIER.exact : MATCH_TIER.prefix + } + return /[_-]/.test(shortcode[index - 1]) ? MATCH_TIER.wordStart : MATCH_TIER.substring +} + export function searchWorkspaceEmojiShortcodes( query: string, limit = 8 @@ -38,11 +43,12 @@ export function searchWorkspaceEmojiShortcodes( return [] } - const matches = SHORTCODE_ENTRIES.filter(({ shortcode }) => - shortcode.startsWith(normalizedQuery) - ).sort( + const matches = STANDARD_EMOJI_SHORTCODE_ENTRIES.flatMap((entry) => { + const tier = matchTier(entry.shortcode, normalizedQuery) + return tier === null ? [] : [{ ...entry, tier }] + }).sort( (left, right) => - Number(right.shortcode === normalizedQuery) - Number(left.shortcode === normalizedQuery) || + left.tier - right.tier || left.shortcode.length - right.shortcode.length || left.shortcode.localeCompare(right.shortcode) ) diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts index fba621b7a9c..63477e2069a 100644 --- a/src/shared/emoji-shortcode-catalog.ts +++ b/src/shared/emoji-shortcode-catalog.ts @@ -1,25 +1,45 @@ -import emojiShortcodes from 'emojibase-data/en/shortcodes/github.json' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' export type StandardEmojiShortcodeEntry = { emoji: string shortcode: string } +// Skin-tone aliases (`wave_tone3`) are ~40% of the dataset and would drown the suggestion list. +const SKIN_TONE_SHORTCODE = /_tone\d(?:-\d)?$/ + +const CATALOG = Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + const shortcodes = (typeof value === 'string' ? [value] : value).filter( + (shortcode) => !SKIN_TONE_SHORTCODE.test(shortcode) + ) + return shortcodes.length > 0 ? [{ emoji: hexcodeToEmoji(hexcode), shortcodes }] : [] +}) + export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] = - Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { - const shortcodes = typeof value === 'string' ? [value] : value - const emoji = hexcodeToEmoji(hexcode) - return shortcodes.map((shortcode) => ({ emoji, shortcode })) - }) + CATALOG.flatMap(({ emoji, shortcodes }) => shortcodes.map((shortcode) => ({ emoji, shortcode }))) const PRIMARY_SHORTCODE_BY_EMOJI = new Map( - Object.entries(emojiShortcodes).map(([hexcode, value]) => { - const shortcodes = typeof value === 'string' ? [value] : value - const shortcode = shortcodes.find((candidate) => /^[a-z]/i.test(candidate)) ?? shortcodes[0] - return [normalizeEmojiLookup(hexcodeToEmoji(hexcode)), shortcode] - }) + CATALOG.map(({ emoji, shortcodes }) => [ + normalizeEmojiLookup(emoji), + primaryShortcode(shortcodes) + ]) ) +/** + * Pick the alias that reads best as a branch or directory name: skip `+1`/`-1` so the name + * starts with a letter, then cryptic stubs (👎 `no`, ✌ `v`) and the `flag_xx` namespacing + * prefix, both of which have a spelled-out alias (`thumbsdown`, `victory`, `germany`). + */ +function primaryShortcode(shortcodes: readonly string[]): string { + const named = shortcodes.filter((candidate) => /^[a-z]/i.test(candidate)) + return ( + named.find((candidate) => candidate.length >= 3 && !candidate.startsWith('flag_')) ?? + named.find((candidate) => candidate.length >= 3) ?? + named[0] ?? + shortcodes[0] + ) +} + const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' }) export function replaceKnownEmojiWithShortcodes(input: string): string { diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 6c2969f53ad..37c9cc9b159 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -190,7 +190,7 @@ test.describe('Create Workspace', () => { } }) - test('enters the Korean flag with the kr shortcode suggestion', async ({ orcaPage }) => { + test('enters the Korean flag with the flag_kr shortcode suggestion', async ({ orcaPage }) => { try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() @@ -198,11 +198,11 @@ test.describe('Create Workspace', () => { const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() - await nameInput.pressSequentially('Launch :kr', { delay: 100 }) + await nameInput.pressSequentially('Launch :flag_kr', { delay: 100 }) const emojiSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') const sourceSuggestions = orcaPage.locator('[data-workspace-source-suggestions="true"]') await expect(emojiSuggestions).toBeVisible() - await expect(emojiSuggestions.getByRole('option', { name: ':kr:' })).toBeVisible() + await expect(emojiSuggestions.getByRole('option', { name: ':flag_kr:' })).toBeVisible() await expect(emojiSuggestions).toHaveAttribute('data-side', 'top') await expect(sourceSuggestions).toBeVisible() await expect(sourceSuggestions).toHaveAttribute('data-side', 'bottom') @@ -211,7 +211,7 @@ test.describe('Create Workspace', () => { await nameInput.pressSequentially(':') await expect(nameInput).toHaveValue('Launch 🇰🇷') - await expect(orcaPage.getByRole('option', { name: /:kr:/i })).toHaveCount(0) + await expect(orcaPage.getByRole('option', { name: /:flag_kr:/i })).toHaveCount(0) await nameInput.pressSequentially(' experiment') await expect(nameInput).toHaveValue('Launch 🇰🇷 experiment') // Keep the asserted result visible in retained proof recordings. From c2e3d13efe84c067b3efb1fc8a6b33eefdfecb2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:40:55 -0700 Subject: [PATCH 121/123] fix(mobile): focus Kimi terminal input after touch (#11865) * fix(mobile): focus terminal input after TUI touch * fix(mobile): defer terminal focus after WebView taps * fix(mobile): reset deferred terminal focus on route blur --- .../app/h/[hostId]/session/[worktreeId].tsx | 39 ++- .../terminal-live-input-affordance.test.ts | 16 +- ...minal-webview-mouse-click-drag-injected.ts | 5 +- .../terminal-webview-mouse-click.test.ts | 1 + .../terminal-webview-scroll-routing.test.ts | 9 +- .../terminal-webview-tap-dispatch-injected.ts | 2 +- .../terminal-webview-tap-routing.test.ts | 34 ++- .../src/terminal/terminal-webview-url-tap.ts | 6 +- .../use-terminal-live-input-focus.test.ts | 252 ++++++++++++++++++ .../terminal/use-terminal-live-input-focus.ts | 86 ++++++ 10 files changed, 413 insertions(+), 37 deletions(-) create mode 100644 mobile/src/terminal/use-terminal-live-input-focus.test.ts create mode 100644 mobile/src/terminal/use-terminal-live-input-focus.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4ec0e564a79..5d9ba390035 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -117,10 +117,10 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, - focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus' import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' @@ -1082,6 +1082,22 @@ export default function SessionScreen() { activeSessionTabType: activeSessionTab?.type }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false + const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef: liveInputRef, + keyboardHeight, + lifecycleIdentity: client, + lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), + liveInputEnabled, + timerRef: liveInputFocusTimerRef + }) + useFocusEffect( + useCallback(() => { + // Expo retains this route while pushed screens are visible. + return resetLiveInputFocus + }, [resetLiveInputFocus]) + ) const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( @@ -3092,17 +3108,6 @@ export default function SessionScreen() { ) sendLiveTerminalInputRef.current = sendLiveTerminalInput - const focusLiveInput = useCallback(() => { - if (!canSend || !liveInputEnabled) { - return - } - focusTerminalLiveInputTarget(liveInputRef.current, { - keyboardHeight, - refocus: () => - scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) - }) - }, [canSend, keyboardHeight, liveInputEnabled]) - const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() sessionTabActionSheetKeyboardHideSubRef.current = null @@ -3177,16 +3182,6 @@ export default function SessionScreen() { }) }, []) - const handleTerminalTap = useCallback( - (handle: string) => { - if (handle !== activeHandleRef.current) { - return - } - focusLiveInput() - }, - [focusLiveInput] - ) - // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss. const handleFileTapActivationSeqRef = useRef(0) const handleFileTap = useCallback( diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 1ceab18ab60..cc681e2056e 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -13,6 +13,10 @@ const commandInputStylesSource = readFileSync( new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), 'utf8' ) +const liveInputFocusSource = readFileSync( + new URL('./use-terminal-live-input-focus.ts', import.meta.url), + 'utf8' +) function liveInputBarBlock(): string { const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') @@ -35,9 +39,15 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') - expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current') - expect(sessionRouteSource).toContain('keyboardHeight') - expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef') + expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') + expect(sessionRouteSource).toContain('return resetLiveInputFocus') + expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') + expect(liveInputFocusSource).toContain('lifecycleIdentity,') + expect(liveInputFocusSource).toContain('resetLiveInputFocus') + expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight') + expect(liveInputFocusSource).toContain( + 'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)' + ) }) it('makes the live keyboard target visible instead of status-only chrome', () => { diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts index bc9c12aa016..54ee8d430a4 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -179,9 +179,8 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` // Why: a dismissing tap only clears the selection (touch parity); it must // not also open a link or focus the keyboard underneath. if (gesture.dismissedSelection) return; - // Plain click == touch tap: links and file paths win, then tracking-mode - // click reports, then keyboard focus. - notifyTerminalSurfaceTap(e.clientX, e.clientY); + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false); }, true); targetSurface.addEventListener('pointercancel', function(e) { diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts index bb2494b62e7..4c519deeef1 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -18,6 +18,7 @@ describe('terminal WebView external mouse click', () => { expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) }) it('dismisses an existing selection with a click without focusing the keyboard', () => { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 391ccd3a840..32bc6dbd529 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -212,10 +212,12 @@ describe('TerminalWebView scroll routing', () => { "document.addEventListener('touchend'", '}, { capture: true, passive: true });' ) - expect(touchEndBlock).toContain('notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y)') + expect(touchEndBlock).toContain( + 'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)' + ) const tapHandlerBlock = sliceBetween( - 'function notifyTerminalSurfaceTap(originX, originY)', + 'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)', "document.addEventListener('touchstart'" ) expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan( @@ -229,6 +231,9 @@ describe('TerminalWebView scroll routing', () => { ) expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });") expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(tapHandlerBlock).toContain( + 'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))' + ) expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });") }) diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts index ebf36ee1ba4..23036f84dfd 100644 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts @@ -163,7 +163,7 @@ export const TERMINAL_TAP_DISPATCH_JS = ` selMode !== 'select' && Date.now() - tapCandidate.t <= TAP_MAX_MS ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y); + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); } clearLongPress(); tapCandidate = null; diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index 2ab0388068d..13a81ef61a1 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -19,12 +19,12 @@ function bodyMarkup(): string { } // Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells. -function makeTerminal(lineRef: { current: string }) { +function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') { return { cols: 80, rows: 24, options: { fontSize: 13 }, - modes: {}, + modes: { mouseTrackingMode }, element: { scrollWidth: 800, scrollHeight: 360 }, _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }, buffer: { @@ -76,13 +76,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string function boot( line: string, - oscLinks?: OscLinkRange[] + oscLinks?: OscLinkRange[], + mouseTrackingMode = 'none' ): { posted: Posted; setLine: (line: string) => void } { const posted: Posted = [] const lineRef = { current: line } const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } w.Terminal = function () { - return makeTerminal(lineRef) + return makeTerminal(lineRef, mouseTrackingMode) } w.ReactNativeWebView = { postMessage(s: string) { @@ -150,6 +151,31 @@ describe('terminal WebView tap routing', () => { expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') }) + it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { + const { posted } = boot('interactive prompt', undefined, 'drag') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect( + posted + .filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap') + .map((message) => message.type) + ).toEqual(['terminal-input', 'terminal-tap']) + }) + + it('reports a non-mouse touch tap without terminal mouse bytes', async () => { + const { posted } = boot('plain prompt') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() + expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + }) + it('opens the URL even right after a width-change reflow', async () => { // Why: scrollback reflow rewraps the local buffer; the tap path must keep // working afterward (the reflow message must not disturb tap routing). diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index 86f87a44198..f5d416d5797 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -205,7 +205,7 @@ export const URL_TAP_WEBVIEW_JS = ` } catch (e) { return 0; } } - function notifyTerminalSurfaceTap(originX, originY) { + function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { var tappedOscLink = oscLinkAtViewportPoint(originX, originY); if (tappedOscLink && tappedOscLink.kind === 'file') { notify({ @@ -245,7 +245,9 @@ export const URL_TAP_WEBVIEW_JS = ` var clickInput = buildMouseClickInput(originX, originY); if (clickInput) { notify({ type: 'terminal-input', bytes: clickInput }); - } else if (!isClickMouseTrackingMode(getMouseTrackingMode())) { + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { notify({ type: 'terminal-tap' }); } } diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts new file mode 100644 index 00000000000..f1b6c5eadb9 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -0,0 +1,252 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalLiveInputFocusTarget, + TerminalLiveInputFocusTimerRef +} from './terminal-live-input' +import { useTerminalLiveInputFocus } from './use-terminal-live-input-focus' + +type HarnessProps = { + readonly activeHandleRef: RefObject + readonly canSend: boolean + readonly inputRef: RefObject + readonly keyboardHeight?: number + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly liveInputEnabled: boolean + readonly timerRef: TerminalLiveInputFocusTimerRef +} + +type FocusHandlers = ReturnType + +function suppressReactTestRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & { + readonly blur: ReturnType + readonly focus: ReturnType +} { + let focused = initiallyFocused + return { + blur: vi.fn(() => { + focused = false + }), + focus: vi.fn(() => { + focused = true + }), + isFocused: () => focused + } +} + +function createTimerRef(): TerminalLiveInputFocusTimerRef { + return { current: null } +} + +function createHarness(initialProps: HarnessProps): { + readonly handlers: () => FocusHandlers + readonly render: (props: HarnessProps) => void + readonly unmount: () => void +} { + let handlers: FocusHandlers | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(props: HarnessProps): null { + handlers = useTerminalLiveInputFocus({ + ...props, + keyboardHeight: props.keyboardHeight ?? 0 + }) + return null + } + + const restoreWarning = suppressReactTestRendererWarning() + try { + act(() => { + renderer = create(createElement(Harness, initialProps)) + }) + } finally { + restoreWarning() + } + if (!handlers || !renderer) { + throw new Error('terminal live input focus harness did not render') + } + + return { + handlers: () => { + if (!handlers) { + throw new Error('terminal live input focus harness is not mounted') + } + return handlers + }, + render: (props) => { + act(() => renderer?.update(createElement(Harness, props))) + }, + unmount: () => { + act(() => renderer?.unmount()) + handlers = null + } + } +} + +function connectedProps( + inputRef: RefObject, + timerRef = createTimerRef(), + activeHandleRef: RefObject = { current: 'terminal-a' } +): HarnessProps { + return { + activeHandleRef, + canSend: true, + inputRef, + lifecycleIdentity: null, + lifecycleKey: 'host-a:worktree-a:connected', + liveInputEnabled: true, + timerRef + } +} + +describe('terminal live input focus hook', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('defers initial terminal surface focus until the WebView touch has completed', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + expect(input.focus).not.toHaveBeenCalled() + + vi.runOnlyPendingTimers() + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('reacquires focus for mouse-aware and non-mouse terminal tap notifications', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + harness.handlers().handleTerminalTap('terminal-a') + vi.runAllTimers() + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).toHaveBeenCalledTimes(2) + harness.unmount() + }) + + it('cancels focus when navigation reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const inputRef: RefObject = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(inputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + const newInput = createFocusTarget() + inputRef.current = newInput + harness.render({ + ...connectedProps(inputRef, timerRef), + lifecycleKey: 'host-a:worktree-b:connected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + expect(newInput.focus).not.toHaveBeenCalled() + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(newInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('drops pending focus when reconnect reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const oldInputRef = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(oldInputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + harness.render({ + ...connectedProps(oldInputRef, timerRef), + canSend: false, + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:disconnected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + + const replacementInput = createFocusTarget() + oldInputRef.current = replacementInput + harness.render({ + ...connectedProps(oldInputRef, timerRef), + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:connected' + }) + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('cancels focus when a retained screen blurs', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const timerRef = createTimerRef() + const harness = createHarness(connectedProps({ current: input }, timerRef)) + + harness.handlers().handleTerminalTap('terminal-a') + harness.handlers().resetLiveInputFocus() + vi.runOnlyPendingTimers() + + expect(timerRef.current).toBeNull() + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + harness.unmount() + }) + + it('does not let stale handle state focus a replacement terminal', () => { + vi.useFakeTimers() + const firstInput = createFocusTarget() + const inputRef: RefObject = { current: firstInput } + const timerRef = createTimerRef() + const activeHandleRef = { current: 'terminal-a' as string | null } + const harness = createHarness(connectedProps(inputRef, timerRef, activeHandleRef)) + harness.handlers().handleTerminalTap('terminal-a') + + const replacementInput = createFocusTarget() + inputRef.current = replacementInput + activeHandleRef.current = 'terminal-b' + harness.render(connectedProps(inputRef, timerRef, activeHandleRef)) + vi.runOnlyPendingTimers() + + expect(firstInput.focus).not.toHaveBeenCalled() + expect(replacementInput.focus).not.toHaveBeenCalled() + harness.handlers().handleTerminalTap('terminal-b') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('keeps the native focus target immediate outside the WebView tap path', () => { + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().focusLiveInput() + + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts new file mode 100644 index 00000000000..b0ab456b721 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -0,0 +1,86 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' +import { + clearTerminalLiveInputFocusTimer, + focusTerminalLiveInputTarget, + scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, + type TerminalLiveInputFocusTimerRef +} from './terminal-live-input' + +type TerminalLiveInputFocusContext = { + readonly canSend: boolean + readonly keyboardHeight: number + readonly liveInputEnabled: boolean +} + +type UseTerminalLiveInputFocusOptions = + TerminalLiveInputFocusContext & { + readonly activeHandleRef: RefObject + readonly inputRef: RefObject + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly timerRef: TerminalLiveInputFocusTimerRef + } + +type TerminalLiveInputFocusHandlers = { + readonly focusLiveInput: () => void + readonly handleTerminalTap: (handle: string) => void + readonly resetLiveInputFocus: () => void +} + +export function useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef, + keyboardHeight, + lifecycleIdentity, + lifecycleKey, + liveInputEnabled, + timerRef +}: UseTerminalLiveInputFocusOptions): TerminalLiveInputFocusHandlers { + const contextRef = useRef({ + canSend, + keyboardHeight, + liveInputEnabled + }) + useLayoutEffect(() => { + contextRef.current = { canSend, keyboardHeight, liveInputEnabled } + }, [canSend, keyboardHeight, liveInputEnabled]) + + const resetLiveInputFocus = useCallback(() => { + clearTerminalLiveInputFocusTimer(timerRef) + inputRef.current?.blur() + }, [inputRef, timerRef]) + + // Retained Expo routes must not carry focus work across navigation or reconnect scopes. + useLayoutEffect(() => resetLiveInputFocus, [lifecycleIdentity, lifecycleKey, resetLiveInputFocus]) + + const focusLiveInput = useCallback(() => { + const context = contextRef.current + if (!context.canSend || !context.liveInputEnabled) { + return + } + focusTerminalLiveInputTarget(inputRef.current, { + keyboardHeight: context.keyboardHeight, + refocus: () => scheduleTerminalLiveInputFocus(timerRef, focusLiveInput) + }) + }, [inputRef, timerRef]) + + const handleTerminalTap = useCallback( + (handle: string) => { + const context = contextRef.current + if (handle !== activeHandleRef.current || !context.canSend || !context.liveInputEnabled) { + return + } + // WKWebView still owns first responder during its touchend notification. + scheduleTerminalLiveInputFocus(timerRef, () => { + if (activeHandleRef.current === handle) { + focusLiveInput() + } + }) + }, + [activeHandleRef, focusLiveInput, timerRef] + ) + + return { focusLiveInput, handleTerminalTap, resetLiveInputFocus } +} From 169ec8f08d09aa1a2590ccc6d020c5c5ca454351 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:42:27 -0700 Subject: [PATCH 122/123] fix(mobile): refresh folder workspace catalog (#11767) --- mobile/src/worktree/host-worktree-refresh.test.ts | 4 +++- mobile/src/worktree/host-worktree-refresh.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index 38ea6ad4aec..fb7e48a7a48 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -101,12 +101,14 @@ describe('startHostWorktreeRefresh', () => { expect(fetchRepoMetadata).not.toHaveBeenCalled() }) - it('force-refreshes repo metadata on reposChanged', () => { + it('refreshes worktrees and repo metadata on reposChanged', () => { start() + fetchWorktrees.mockClear() fetchRepoMetadata.mockClear() eventListener?.({ type: 'reposChanged' }) + expect(fetchWorktrees).toHaveBeenCalledOnce() expect(fetchRepoMetadata).toHaveBeenCalledOnce() expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true }) }) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 7fa3161740a..264b3c6e266 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -69,6 +69,8 @@ export function startHostWorktreeRefresh({ return } if (event.type === 'reposChanged') { + // Why: folder workspace mutations publish reposChanged, and worktree.ps owns this catalog. + void fetchWorktrees() void fetchRepoMetadata({ force: true, queueIfInFlight: true }) } else if (event.type === 'worktreesChanged') { void fetchWorktrees() From edb5607e28fb587be44079eb4f77efe90b3841ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:48:24 -0700 Subject: [PATCH 123/123] ci: block new root-level entries (#11903) * ci: guard repository root additions * fix: clear existing type-aware lint warnings --- .../scripts/check-root-directory-entries.sh | 37 +++++++ .github/workflows/pr.yml | 20 ++++ .../check-root-directory-entries.test.mjs | 99 +++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../terminal-cold-park-resource-bench.mjs | 9 +- .../terminal-cold-park-reveal-bench.mjs | 9 +- tests/tools/win-update-e2e/app-driver.mjs | 4 +- .../tools/win-update-e2e/installer-steps.mjs | 2 +- 8 files changed, 172 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/check-root-directory-entries.sh create mode 100644 config/scripts/check-root-directory-entries.test.mjs diff --git a/.github/scripts/check-root-directory-entries.sh b/.github/scripts/check-root-directory-entries.sh new file mode 100755 index 00000000000..c07d7d40163 --- /dev/null +++ b/.github/scripts/check-root-directory-entries.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +base_sha=$1 +head_sha=$2 +git rev-parse --verify "${base_sha}^{tree}" >/dev/null +git rev-parse --verify "${head_sha}^{tree}" >/dev/null + +declare -A base_entries=() +while IFS= read -r -d '' entry; do + base_entries["$entry"]=1 +done < <(git ls-tree -z --name-only "$base_sha") + +blocked_entries=() +while IFS= read -r -d '' entry; do + if [[ -z "${base_entries[$entry]+present}" ]]; then + blocked_entries+=("$entry") + fi +done < <(git ls-tree -z --name-only "$head_sha") + +if (( ${#blocked_entries[@]} == 0 )); then + echo "Root directory guard passed: no new root-level files or folders." + exit 0 +fi + +echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page." +echo "Root directory guard failed." +echo "New root-level files or folders are not allowed because they bloat the GitHub landing page." +echo "Move each new entry under an existing top-level directory." +printf 'Blocked entries:\n' +printf ' %s\n' "${blocked_entries[@]}" +exit 1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9ae0aa2a659..c2c3119456b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -93,6 +93,23 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements + root_directory_guard: + name: root directory guard + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Reject new root-level files and folders + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA" + typecheck: runs-on: ubuntu-latest @@ -377,6 +394,7 @@ jobs: if: always() needs: - static_analysis + - root_directory_guard - typecheck - git_compatibility - shell_contracts @@ -397,6 +415,7 @@ jobs: - name: Require successful checks env: STATIC_ANALYSIS: ${{ needs.static_analysis.result }} + ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }} TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} @@ -406,6 +425,7 @@ jobs: run: | for result in \ "$STATIC_ANALYSIS" \ + "$ROOT_DIRECTORY_GUARD" \ "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs new file mode 100644 index 00000000000..e8d3bb9bc4c --- /dev/null +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -0,0 +1,99 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') +const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh') +const tempDirs = [] + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() +} + +function makeFixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-root-directory-guard-')) + tempDirs.push(root) + git(root, ['init', '--quiet']) + git(root, ['config', 'user.email', 'root-directory-guard-test@example.com']) + git(root, ['config', 'user.name', 'Root Directory Guard Test']) + mkdirSync(join(root, 'config'), { recursive: true }) + writeFileSync(join(root, 'config', 'base.txt'), 'base\n') + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'base']) + return { root, base: git(root, ['rev-parse', 'HEAD']) } +} + +function commitFiles(root, files) { + for (const [relativePath, contents] of files) { + const target = join(root, relativePath) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, contents) + } + git(root, ['add', '-A']) + git(root, ['commit', '--quiet', '-m', 'head']) + return git(root, ['rev-parse', 'HEAD']) +} + +function runGuard({ root, base, head }) { + return spawnSync('bash', [guardScript, base, head], { + cwd: root, + encoding: 'utf8' + }) +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('root directory guard', () => { + it('allows additions inside an existing top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['config/new.txt', 'nested\n']]) + + const result = runGuard({ ...fixture, head }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('no new root-level files or folders') + }) + + it('rejects a new root-level file with the landing-page message', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-root.md', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('bloat the GitHub landing page') + expect(output).toContain('new-root.md') + }) + + it('rejects a new top-level directory', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']]) + + const result = runGuard({ ...fixture, head }) + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('new-folder') + }) + + it('is wired into the PR verify gate', () => { + const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const guardJob = workflow.jobs.root_directory_guard + const guardStep = guardJob.steps.find( + (step) => step.name === 'Reject new root-level files and folders' + ) + + expect(guardJob.name).toBe('root directory guard') + expect(guardJob.steps[0].with['fetch-depth']).toBe(0) + expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh') + expect(workflow.jobs.verify.needs).toContain('root_directory_guard') + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 5afa1107419..d1e90fad17a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -173,6 +173,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'static_analysis', + 'root_directory_guard', 'typecheck', 'git_compatibility', 'shell_contracts', diff --git a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs index b70fa88a2a7..63b7222a93d 100644 --- a/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs @@ -28,14 +28,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.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' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const PARK_DELAY_MS = 1_500 @@ -454,7 +456,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-res-${args.label}-${stamp}.json` diff --git a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs index b28591535c6..a01cbf2cc3a 100644 --- a/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs +++ b/tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs @@ -34,14 +34,16 @@ import { stopDevApp, waitForStoreReady } from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs' -import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs' +import { + createCompletedOnboardingProfile, + safeRemoveLocalDirectory +} from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.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' const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) const scenarioTimeoutMs = 300_000 @@ -582,7 +584,8 @@ async function main() { args.reportPath ?? path.join( rootDir, - 'tests', 'tools', + 'tests', + 'tools', 'benchmarks', 'results', `cold-park-${args.label}-${stamp}.json` diff --git a/tests/tools/win-update-e2e/app-driver.mjs b/tests/tools/win-update-e2e/app-driver.mjs index b8d155c5752..72603fba921 100644 --- a/tests/tools/win-update-e2e/app-driver.mjs +++ b/tests/tools/win-update-e2e/app-driver.mjs @@ -217,7 +217,7 @@ export async function captureFailureDiagnostics(page, dir, label) { * An expected tab id prevents post-restore probes from accepting another tab. */ export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTabId = null) { const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() await surface.waitFor({ state: 'visible', timeout: timeoutMs }) @@ -399,7 +399,7 @@ export async function focusActiveTerminal(page, terminalTabId = null) { // focusing so typed commands actually reach the shell. await dismissKnownOverlays(page) const selector = terminalTabId - ? `[data-terminal-tab-id="${terminalTabId}"]:visible` + ? `[data-terminal-tab-id="${String(terminalTabId)}"]:visible` : TERMINAL_SURFACE_VISIBLE const surface = page.locator(selector).first() const click = surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }) diff --git a/tests/tools/win-update-e2e/installer-steps.mjs b/tests/tools/win-update-e2e/installer-steps.mjs index 2ae7af4b6ed..92ab9f9e1ca 100644 --- a/tests/tools/win-update-e2e/installer-steps.mjs +++ b/tests/tools/win-update-e2e/installer-steps.mjs @@ -85,7 +85,7 @@ export function silentInstall(setupExe, { timeoutMs = 180_000, installDir = null // no other flags for a per-user install. /D, when present, MUST be last. const args = ['/S'] if (installDir) { - args.push(`/D=${installDir}`) + args.push(`/D=${String(installDir)}`) } const proc = spawnSync(setupExe, args, { encoding: 'utf8' }) if (proc.error) {