From 9a2379211e7bf17b5075a13b352d6deb8c3f113c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:23:28 -0700 Subject: [PATCH 01/54] Search worktrees by cached review metadata (#8505) * feat(renderer): search worktrees by review metadata * fix(renderer): scope review search by execution host --- .../src/components/WorktreeJumpPalette.tsx | 39 +++- .../worktree-checks-review-index.test.ts | 180 +++++++++++++++ .../cmd-j/worktree-checks-review-index.ts | 84 +++++++ .../worktree-palette-cache-inputs.test.ts | 42 ++++ .../cmd-j/worktree-palette-cache-inputs.ts | 29 +++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/worktree-palette-review-match.ts | 47 ++++ .../src/lib/worktree-palette-search.test.ts | 209 ++++++++++++++++++ .../src/lib/worktree-palette-search.ts | 57 ++--- 13 files changed, 655 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts create mode 100644 src/renderer/src/components/cmd-j/worktree-checks-review-index.ts create mode 100644 src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts create mode 100644 src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts create mode 100644 src/renderer/src/lib/worktree-palette-review-match.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 8b1ee8fd01d..9b6fcef5b29 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -104,6 +104,9 @@ import { getCmdJQuickActions, CREATE_WORKSPACE_QUICK_ACTION_ID } from '@/components/cmd-j/quick-actions' +import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' +import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getComposerEligibleRepos, resolveComposerGitRepoId @@ -382,8 +385,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const agentStatusEpoch = useAppStore((s) => visible || statusInputsLingering ? s.agentStatusEpoch : 0 ) - const prCache = useAppStore((s) => s.prCache) - const issueCache = useAppStore((s) => s.issueCache) + const { prCache, issueCache, hostedReviewCache } = useAppStore( + useShallow((s) => selectWorktreePaletteCacheInputs(s, visible || statusInputsLingering)) + ) const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeTabType = useAppStore((s) => s.activeTabType) @@ -439,6 +443,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const preserveCreateLookupOnCloseRef = useRef(false) const repoMap = useMemo(() => new Map(repos.map((r) => [r.id, r])), [repos]) + const repoByHostIdentity = useMemo( + () => new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])), + [repos] + ) const hostLabelOverrides = useMemo(() => getHostDisplayLabelOverrides(settings), [settings]) // Why: host badges only appear when more than one execution host exists; reuse // the same registry the sidebar host-scope strip builds so labels stay in sync. @@ -622,6 +630,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { [browserSortedWorktrees] ) + const checksReviewByWorktree = useMemo( + () => + buildWorktreeChecksReviewIndex({ + worktrees: allWorktrees, + repoByHostIdentity, + prCache, + hostedReviewCache, + settings + }), + [allWorktrees, hostedReviewCache, prCache, repoByHostIdentity, settings] + ) + const worktreeMatches = useMemo( () => searchWorktrees( @@ -630,9 +650,18 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { repoMap, prCache, issueCache, - getWorkspacePortsByWorktreeId(workspacePortScan) + getWorkspacePortsByWorktreeId(workspacePortScan), + checksReviewByWorktree ), - [sortedWorktrees, deferredQuery, repoMap, prCache, issueCache, workspacePortScan] + [ + sortedWorktrees, + deferredQuery, + repoMap, + prCache, + issueCache, + workspacePortScan, + checksReviewByWorktree + ] ) const browserPageEntries = useMemo(() => { @@ -2364,5 +2393,7 @@ function getPaletteSupportingTextLabel( return translate('worktreeJumpPalette.matchLabel.port', 'Port') case 'pr': return translate('worktreeJumpPalette.matchLabel.pr', 'PR') + case 'mr': + return translate('worktreeJumpPalette.matchLabel.mr', 'MR') } } diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts new file mode 100644 index 00000000000..78889b1d2db --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { buildWorktreeChecksReviewIndex } from './worktree-checks-review-index' + +const repo: Repo = { + id: 'repo-1', + path: '/remote/orca', + displayName: 'orca', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'ssh:staging' +} + +const worktree: Worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/remote/orca-worktrees/search', + head: 'abc123', + branch: 'refs/heads/feature/search', + isBare: false, + isMainWorktree: false, + displayName: 'Search reviews', + comment: '', + linkedIssue: null, + linkedPR: 42, + linkedLinearIssue: null, + hostId: 'ssh:staging', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} + +function makePR(): PRInfo { + return { + number: 42, + title: 'Search worktrees by their pull requests', + state: 'open', + url: 'https://github.com/acme/orca/pull/42', + checksStatus: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } +} + +function makeGitLabReview(): HostedReviewInfo { + return { + provider: 'gitlab', + number: 17, + title: 'Search worktrees by merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'pending', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'UNKNOWN' + } +} + +describe('buildWorktreeChecksReviewIndex', () => { + it('reads the same host-scoped GitHub PR cache entry as Checks', () => { + const key = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [worktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [key]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.get(worktree)).toMatchObject({ + provider: 'github', + number: 42, + title: 'Search worktrees by their pull requests' + }) + }) + + it('uses the GitLab review selected by Checks instead of stale GitHub metadata', () => { + const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 } + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + const reviewKey = getHostedReviewCacheKey( + repo.path, + 'feature/search', + null, + repo.id, + repo.connectionId, + repo.executionHostId, + true + ) + const gitLabReview = makeGitLabReview() + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [gitLabWorktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: { [reviewKey]: { data: gitLabReview, fetchedAt: 1 } }, + settings: null + }) + + expect(reviews.get(gitLabWorktree)).toBe(gitLabReview) + }) + + it('records when a non-GitHub link suppresses stale GitHub metadata before its review loads', () => { + const gitLabWorktree = { ...worktree, linkedGitLabMR: 17 } + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [gitLabWorktree], + repoByHostIdentity: new Map([[getRepoHostIdentity(repo), repo]]), + prCache: { [prKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.has(gitLabWorktree)).toBe(true) + expect(reviews.get(gitLabWorktree)).toBeNull() + }) + + it('keeps same-id worktrees isolated across execution hosts', () => { + const localRepo: Repo = { + ...repo, + path: '/local/orca', + executionHostId: 'local' + } + const localWorktree: Worktree = { ...worktree, hostId: 'local' } + const sshWorktree: Worktree = { ...worktree, hostId: 'ssh:staging' } + const sshKey = getGitHubPRCacheKey( + repo.path, + repo.id, + 'feature/search', + null, + repo.connectionId, + repo.executionHostId, + true + ) + + const reviews = buildWorktreeChecksReviewIndex({ + worktrees: [localWorktree, sshWorktree], + repoByHostIdentity: new Map([ + [getRepoHostIdentity(localRepo), localRepo], + [getRepoHostIdentity(repo), repo] + ]), + prCache: { [sshKey]: { data: makePR(), fetchedAt: 1 } }, + hostedReviewCache: {}, + settings: null + }) + + expect(reviews.has(localWorktree)).toBe(false) + expect(reviews.get(sshWorktree)).toMatchObject({ provider: 'github', number: 42 }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts new file mode 100644 index 00000000000..507eee5543d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-checks-review-index.ts @@ -0,0 +1,84 @@ +import { branchName } from '@/lib/git-utils' +import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' +import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' +import type { AppState } from '@/store/types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo, Worktree } from '../../../../shared/types' +import { selectChecksPanelReview } from '../right-sidebar/checks-panel-review' + +type WorktreeChecksReviewIndexArgs = { + worktrees: readonly Worktree[] + repoByHostIdentity: ReadonlyMap + prCache: AppState['prCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null + settings: AppState['settings'] +} + +export function buildWorktreeChecksReviewIndex({ + worktrees, + repoByHostIdentity, + prCache, + hostedReviewCache, + settings +}: WorktreeChecksReviewIndexArgs): Map { + const reviews = new Map() + if (!prCache || !hostedReviewCache) { + return reviews + } + + for (const worktree of worktrees) { + const repo = repoByHostIdentity.get( + getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) + ) + if (!repo) { + continue + } + const branch = branchName(worktree.branch) + const prKey = getGitHubPRCacheKey( + repo.path, + repo.id, + branch, + settings, + repo.connectionId, + repo.executionHostId, + true + ) + const hostedReviewKey = getHostedReviewCacheKey( + repo.path, + branch, + settings, + repo.id, + repo.connectionId, + repo.executionHostId, + true + ) + // Why: Cmd+J should expose exactly the review metadata Checks has already + // resolved, without starting another provider lookup from the search path. + const review = selectChecksPanelReview({ + hostedReview: hostedReviewCache[hostedReviewKey]?.data, + pr: prCache[prKey]?.data, + linkedGitLabMR: worktree.linkedGitLabMR ?? null, + linkedBitbucketPR: worktree.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: worktree.linkedGiteaPR ?? null + }) + if (review) { + // Why: persisted IDs can be identical across execution hosts; the search + // scope preserves these object references while sorting and filtering. + reviews.set(worktree, review) + } else if ( + worktree.linkedGitLabMR != null || + worktree.linkedBitbucketPR != null || + worktree.linkedAzureDevOpsPR != null || + worktree.linkedGiteaPR != null + ) { + // Why: an empty Checks selection for a non-GitHub link is authoritative; + // omitting it would let Cmd+J surface stale GitHub metadata as a fallback. + reviews.set(worktree, null) + } + } + + return reviews +} diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts new file mode 100644 index 00000000000..2e9254e86bf --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + EMPTY_WORKTREE_PALETTE_CACHE_INPUTS, + selectWorktreePaletteCacheInputs +} from './worktree-palette-cache-inputs' + +describe('selectWorktreePaletteCacheInputs', () => { + it('stays referentially stable across review cache writes while the palette is closed', () => { + const emptyState: Pick = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } + const filledState: Pick = { + prCache: { pr: { data: null, fetchedAt: 1 } }, + issueCache: { issue: { data: null, fetchedAt: 1 } }, + hostedReviewCache: { review: { data: null, fetchedAt: 1 } } + } + + const before = selectWorktreePaletteCacheInputs(emptyState, false) + const after = selectWorktreePaletteCacheInputs(filledState, false) + + expect(before).toBe(EMPTY_WORKTREE_PALETTE_CACHE_INPUTS) + expect(after).toBe(before) + }) + + it('returns live cache identities while the palette is visible', () => { + const state = { + prCache: {}, + issueCache: {}, + hostedReviewCache: {} + } as Pick + + const selected = selectWorktreePaletteCacheInputs(state, true) + + expect(selected).toEqual(state) + expect(selected.prCache).toBe(state.prCache) + expect(selected.issueCache).toBe(state.issueCache) + expect(selected.hostedReviewCache).toBe(state.hostedReviewCache) + }) +}) diff --git a/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts new file mode 100644 index 00000000000..1a1c234f12d --- /dev/null +++ b/src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.ts @@ -0,0 +1,29 @@ +import type { AppState } from '@/store/types' + +export type WorktreePaletteCacheInputs = { + prCache: AppState['prCache'] | null + issueCache: AppState['issueCache'] | null + hostedReviewCache: AppState['hostedReviewCache'] | null +} + +export const EMPTY_WORKTREE_PALETTE_CACHE_INPUTS: WorktreePaletteCacheInputs = Object.freeze({ + prCache: null, + issueCache: null, + hostedReviewCache: null +}) + +export function selectWorktreePaletteCacheInputs( + state: Pick, + active: boolean +): WorktreePaletteCacheInputs { + // Why: the palette stays mounted while closed; cache replacement from Checks + // must not rerender it when no search results are visible. + if (!active) { + return EMPTY_WORKTREE_PALETTE_CACHE_INPUTS + } + return { + prCache: state.prCache, + issueCache: state.issueCache, + hostedReviewCache: state.hostedReviewCache + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 40492be7b71..d6d8725c2d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comment", "issue": "Issue", + "mr": "MR", "port": "Port", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f909b7687b9..a2bbc411665 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "Comentario", "issue": "Issue", + "mr": "MR", "port": "Puerto", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 33bc07fbfd1..8ce317e4ae4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "コメント", "issue": "Issue", + "mr": "MR", "port": "ポート", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 67187a4b21c..37326de084a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "댓글", "issue": "이슈", + "mr": "MR", "port": "포트", "pr": "PR" } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 44e1e44a579..060903dce0e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -73,6 +73,7 @@ "matchLabel": { "comment": "评论", "issue": "议题", + "mr": "MR", "port": "端口", "pr": "PR" } diff --git a/src/renderer/src/lib/worktree-palette-review-match.ts b/src/renderer/src/lib/worktree-palette-review-match.ts new file mode 100644 index 00000000000..9945722a366 --- /dev/null +++ b/src/renderer/src/lib/worktree-palette-review-match.ts @@ -0,0 +1,47 @@ +import type { HostedReviewInfo } from '../../../shared/hosted-review' + +type SearchableReview = Pick +type WorktreePaletteReviewMatch = { + labelKind: 'pr' | 'mr' + text: string + matchRange: { start: number; end: number } +} + +export function matchWorktreePaletteReview( + review: SearchableReview, + query: string, + numericQuery: string +): WorktreePaletteReviewMatch | null { + const isMergeRequest = review.provider === 'gitlab' + const numberPrefix = isMergeRequest ? 'MR !' : 'PR #' + const hasPullRequestSigil = query.startsWith('#') + const hasMergeRequestSigil = query.startsWith('!') + const sigilMatchesProvider = + (!hasPullRequestSigil && !hasMergeRequestSigil) || + (hasPullRequestSigil && !isMergeRequest) || + (hasMergeRequestSigil && isMergeRequest) + const reviewNumericQuery = hasMergeRequestSigil ? query.slice(1) : numericQuery + const reviewNumberIndex = sigilMatchesProvider + ? String(review.number).indexOf(reviewNumericQuery) + : -1 + if (reviewNumericQuery && reviewNumberIndex !== -1) { + return { + labelKind: isMergeRequest ? 'mr' : 'pr', + text: `${numberPrefix}${review.number}`, + matchRange: { + start: numberPrefix.length + reviewNumberIndex, + end: numberPrefix.length + reviewNumberIndex + reviewNumericQuery.length + } + } + } + + const titleIndex = review.title.toLowerCase().indexOf(query) + if (titleIndex === -1) { + return null + } + return { + labelKind: isMergeRequest ? 'mr' : 'pr', + text: review.title, + matchRange: { start: titleIndex, end: titleIndex + query.length } + } +} diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index ff63dd75ba5..7059ee4ad51 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -5,6 +5,7 @@ import { isWorktreePaletteQueryTooLarge } from './worktree-palette-query-bounds' import type { Repo, Worktree } from '../../../shared/types' +import type { HostedReviewInfo } from '../../../shared/hosted-review' function makeWorktree(overrides: Partial = {}): Worktree { return { @@ -172,6 +173,214 @@ describe('worktree-palette-search', () => { }) }) + it('matches the GitLab review title and number already selected by Checks', () => { + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Reuse checks tab review metadata', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const worktree = makeWorktree() + const reviews = new Map([[worktree, review]]) + + const titleResults = searchWorktrees( + [worktree], + 'checks tab', + repoMap, + null, + null, + undefined, + reviews + ) + const numberResults = searchWorktrees( + [worktree], + '!17', + repoMap, + null, + null, + undefined, + reviews + ) + + expect(titleResults[0].supportingText).toEqual({ + labelKind: 'mr', + text: review.title, + matchRange: { start: 6, end: 16 } + }) + expect(numberResults[0].supportingText).toEqual({ + labelKind: 'mr', + text: 'MR !17', + matchRange: { start: 4, end: 6 } + }) + }) + + it('does not search stale GitHub cache metadata when Checks selected another review', () => { + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Current merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const staleWorktree = makeWorktree({ + branch: 'refs/heads/feature/palette-refresh', + linkedPR: 99 + }) + const reviews = new Map([[staleWorktree, review]]) + + expect( + searchWorktrees( + [staleWorktree], + 'stale github title', + repoMap, + { + '/repo/orca::feature/palette-refresh': { + data: { number: 99, title: 'Stale GitHub title' } + } + }, + null, + undefined, + reviews + ) + ).toEqual([]) + + expect( + searchWorktrees([staleWorktree], '#99', repoMap, null, null, undefined, reviews) + ).toEqual([]) + }) + + it('does not search stale GitHub metadata while a linked non-GitHub review is loading', () => { + const stalePRCache = { + '/repo/orca::feature/palette-refresh': { + data: { number: 99, title: 'Stale GitHub title' } + } + } + const staleWorktree = makeWorktree({ + branch: 'refs/heads/feature/palette-refresh', + linkedPR: 99, + linkedGitLabMR: 17 + }) + const authoritativeEmptyReviews = new Map([ + [staleWorktree, null] + ]) + + expect( + searchWorktrees( + [staleWorktree], + 'stale github title', + repoMap, + stalePRCache, + null, + undefined, + authoritativeEmptyReviews + ) + ).toEqual([]) + expect( + searchWorktrees( + [staleWorktree], + '#99', + repoMap, + stalePRCache, + null, + undefined, + authoritativeEmptyReviews + ) + ).toEqual([]) + }) + + it('keeps review matches isolated between same-id worktrees on different hosts', () => { + const localWorktree = makeWorktree({ hostId: 'local' }) + const sshWorktree = makeWorktree({ hostId: 'ssh:staging' }) + const review: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'Remote-only merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + + const results = searchWorktrees( + [localWorktree, sshWorktree], + 'remote-only', + repoMap, + null, + null, + undefined, + new Map([[sshWorktree, review]]) + ) + + expect(results).toHaveLength(1) + expect(results[0].supportingText?.text).toBe(review.title) + }) + + it('scopes PR and MR number sigils to their providers', () => { + const gitHubReview: HostedReviewInfo = { + provider: 'github', + number: 42, + title: 'GitHub pull request', + state: 'open', + url: 'https://github.com/acme/orca/pull/42', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const gitLabReview: HostedReviewInfo = { + provider: 'gitlab', + number: 17, + title: 'GitLab merge request', + state: 'open', + url: 'https://gitlab.com/acme/orca/-/merge_requests/17', + status: 'success', + updatedAt: '2026-07-12T00:00:00Z', + mergeable: 'MERGEABLE' + } + const gitHubWorktree = makeWorktree() + const gitLabWorktree = makeWorktree() + + expect( + searchWorktrees( + [gitHubWorktree], + '!42', + repoMap, + null, + null, + undefined, + new Map([[gitHubWorktree, gitHubReview]]) + ) + ).toEqual([]) + expect( + searchWorktrees( + [gitLabWorktree], + '#17', + repoMap, + null, + null, + undefined, + new Map([[gitLabWorktree, gitLabReview]]) + ) + ).toEqual([]) + expect( + searchWorktrees( + [makeWorktree({ linkedIssue: 42 })], + '!42', + repoMap, + null, + null, + new Map([['wt-1', [{ port: 42 }]]]) + ) + ).toEqual([]) + }) + it('preserves input order when query matches a repo name', () => { const worktrees = [ makeWorktree({ diff --git a/src/renderer/src/lib/worktree-palette-search.ts b/src/renderer/src/lib/worktree-palette-search.ts index e7a5933050b..f8dce99582b 100644 --- a/src/renderer/src/lib/worktree-palette-search.ts +++ b/src/renderer/src/lib/worktree-palette-search.ts @@ -1,8 +1,10 @@ import { branchName } from '@/lib/git-utils' import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github' +import type { HostedReviewInfo } from '../../../shared/hosted-review' import type { Repo, Worktree } from '../../../shared/types' import { extractWorktreePaletteCommentSnippet } from './worktree-palette-comment-snippet' import { isWorktreePaletteQueryTooLarge } from './worktree-palette-query-bounds' +import { matchWorktreePaletteReview } from './worktree-palette-review-match' export type MatchRange = { start: number; end: number } @@ -16,7 +18,7 @@ export type PaletteMatchedField = | 'port' export type PaletteSupportingText = { - labelKind: 'comment' | 'pr' | 'issue' | 'port' + labelKind: 'comment' | 'pr' | 'mr' | 'issue' | 'port' text: string matchRange: MatchRange | null } @@ -69,7 +71,8 @@ export function searchWorktrees( repoMap: Map, prCache: Record | null, issueCache: Record | null, - workspacePortsByWorktreeId?: Map + workspacePortsByWorktreeId?: Map, + checksReviewByWorktree?: ReadonlyMap ): PaletteSearchResult[] { if (isWorktreePaletteQueryTooLarge(query)) { return [] @@ -199,42 +202,30 @@ export function searchWorktrees( } const repo = repoMap.get(worktree.repoId) + const checksReview = checksReviewByWorktree?.get(worktree) + const hasChecksReviewEntry = checksReview !== undefined + if (checksReview) { + const supportingText = matchWorktreePaletteReview(checksReview, q, numericQuery) + if (supportingText) { + results.push(makeResult(worktree.id, 'pr', { supportingText })) + continue + } + } + const prKey = repo ? `${repo.path}::${branch}` : '' - const pr = prKey && prCache ? prCache[prKey]?.data : undefined + const pr = !hasChecksReviewEntry && prKey && prCache ? prCache[prKey]?.data : undefined if (pr) { - const prText = `PR #${pr.number}` - const prNumberIndex = String(pr.number).indexOf(numericQuery) - if (prNumberIndex !== -1) { - results.push( - makeResult(worktree.id, 'pr', { - supportingText: { - labelKind: 'pr', - text: prText, - matchRange: { - start: 'PR #'.length + prNumberIndex, - end: 'PR #'.length + prNumberIndex + numericQuery.length - } - } - }) - ) + const supportingText = matchWorktreePaletteReview( + { ...pr, provider: 'github' }, + q, + numericQuery + ) + if (supportingText) { + results.push(makeResult(worktree.id, 'pr', { supportingText })) continue } - - const prTitleIndex = pr.title.toLowerCase().indexOf(q) - if (prTitleIndex !== -1) { - results.push( - makeResult(worktree.id, 'pr', { - supportingText: { - labelKind: 'pr', - text: pr.title, - matchRange: { start: prTitleIndex, end: prTitleIndex + q.length } - } - }) - ) - continue - } - } else if (worktree.linkedPR != null) { + } else if (!hasChecksReviewEntry && worktree.linkedPR != null) { const prText = `PR #${worktree.linkedPR}` const prNumberIndex = String(worktree.linkedPR).indexOf(numericQuery) if (prNumberIndex !== -1) { From 527c692b71402d3c26a0c26721d6fa9f8c28e47d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:42:55 -0700 Subject: [PATCH 02/54] Improve git pull on remote (#8524) * Fall back to a merge when a divergent pull has no reconciliation strateg - Git 2.27+ refuses `git pull` on divergent branches unless pull.rebase or pull.ff is configured. Retry with `--no-rebase` (Git's historical default) so pulls succeed out of the box on fresh hosts. - Skip the fallback whenever the caller already specified a reconciliation strategy (e.g. --ff-only, --rebase) so explicit policies still fail as expected on divergence. - Applied identically in the local git pull path and the relay/SSH git handler so both surfaces behave the same way. * Refactor divergent-pull merge fallback into shared helper Extracts the retry-as-merge logic (duplicated between local git and relay SSH pull paths) into `runPullWithDivergenceFallback` in git-remote-error.ts, so both callers share one implementation and test coverage. --- src/main/git/remote.test.ts | 84 ++++++++++++++++++ src/main/git/remote.ts | 17 ++-- .../git-handler-pull-reconciliation.test.ts | 28 +++++- src/relay/git-handler.ts | 52 ++++++----- src/shared/git-remote-error.test.ts | 88 ++++++++++++++++++- src/shared/git-remote-error.ts | 47 ++++++++++ 6 files changed, 284 insertions(+), 32 deletions(-) diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 166db56079d..61b95d0e50d 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -363,6 +363,90 @@ describe('git remote operations', () => { ]) }) + it('retries a divergent pull as a merge when no strategy is configured', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + gitExecFileAsyncMock + // First attempt: plain pull rejects with git's reconciliation error. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // Fallback attempt: pull --no-rebase (merge) succeeds. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPull('/repo') + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }], + [['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }], + [['pull'], { cwd: '/repo' }], + [['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: '/repo' }], + [['rev-parse', '--abbrev-ref', 'HEAD@{u}'], { cwd: '/repo' }], + [['pull', '--no-rebase'], { cwd: '/repo' }] + ]) + }) + + it('does not retry a fast-forward-only pull that fails on divergence', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce( + new Error('Command failed: git pull\nfatal: Not possible to fast-forward, aborting.') + ) + + await expect(gitFastForward('/repo')).rejects.toThrow('Not possible to fast-forward') + // No fallback attempt: only the three probe/pull calls ran. + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(3) + }) + + it('retries a divergent pushTarget pull as a merge when no strategy is configured', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + gitExecFileAsyncMock + // First attempt: validate the target, then the plain pull rejects. + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // Fallback attempt: re-validate, then pull --no-rebase (merge) succeeds. + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitPull('/repo', { remoteName: 'fork', branchName: 'feature/fix' }) + + // The merge flag is spliced ahead of the positional remote/branch args. + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['pull', 'fork', 'feature/fix'], { cwd: '/repo' }], + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['pull', '--no-rebase', 'fork', 'feature/fix'], { cwd: '/repo' }] + ]) + }) + + it('surfaces a normalized error and does not loop when the merge fallback itself fails', async () => { + const divergentError = new Error( + 'Command failed: git pull\n' + 'fatal: Need to specify how to reconcile divergent branches.' + ) + const mergeConflictError = new Error( + 'Command failed: git pull --no-rebase\nCONFLICT (content): Merge conflict in file.txt' + ) + gitExecFileAsyncMock + // First attempt fails with the reconciliation error. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(divergentError) + // The single merge fallback then fails on a real conflict. + .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'origin/feature\n', stderr: '' }) + .mockRejectedValueOnce(mergeConflictError) + + await expect(gitPull('/repo')).rejects.toThrow() + // At-most-once retry: probe+pull, then probe+fallback-pull — no further attempts. + expect(gitExecFileAsyncMock.mock.calls).toHaveLength(6) + }) + it('pulls the same-name origin branch for legacy base-tracking worktrees', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\n', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index ab7fba775f9..5c3f40cafe0 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -1,4 +1,7 @@ -import { normalizeGitErrorMessage } from '../../shared/git-remote-error' +import { + normalizeGitErrorMessage, + runPullWithDivergenceFallback +} from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' import { resolveGitRemoteRebaseSource } from '../../shared/git-rebase-source' @@ -214,11 +217,11 @@ async function gitPullWithArgs( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - try { + const runPull = async (effectiveArgs: string[]): Promise => { if (pushTarget) { const target = await validateGitPushTarget(worktreePath, pushTarget, options) await gitExecFileAsync( - ['pull', ...pullArgs, target.remoteName, target.branchName], + ['pull', ...effectiveArgs, target.remoteName, target.branchName], gitOptionsForWorktree(worktreePath, options) ) return @@ -230,13 +233,17 @@ async function gitPullWithArgs( // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. await gitExecFileAsync( - ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], + ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], gitOptionsForWorktree(worktreePath, options) ) return } - await gitExecFileAsync(['pull', ...pullArgs], gitOptionsForWorktree(worktreePath, options)) + await gitExecFileAsync(['pull', ...effectiveArgs], gitOptionsForWorktree(worktreePath, options)) + } + + try { + await runPullWithDivergenceFallback(pullArgs, runPull) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts index 1820faa0fec..5db00d16202 100644 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ b/src/relay/git-handler-pull-reconciliation.test.ts @@ -86,13 +86,33 @@ describe('GitHandler pull reconciliation', () => { return consumerDir } - it('explains how to configure a pull policy when divergent branches have no strategy', async () => { + it('falls back to a merge when divergent branches have no configured strategy', async () => { const consumerDir = createDivergentFixture() - await expect(dispatcher.callRequest('git.pull', { worktreePath: consumerDir })).rejects.toThrow( - 'Pull needs a Git pull policy for divergent branches. Configure one for this repository' - ) + await expect( + dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) + ).resolves.not.toThrow() + // Merge reconciliation yields a two-parent commit and both sides' files. + const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) + expect(parentRefs).toHaveLength(2) + expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) + expect(existsSync(path.join(consumerDir, 'local.txt'))).toBe(true) + expect(execGit(consumerDir, ['status', '--short'])).toBe('') + }, 15_000) + + it('preserves configured fast-forward-only pull semantics on divergent branches', async () => { + const consumerDir = createDivergentFixture() + execGit(consumerDir, ['config', 'pull.ff', 'only']) + + // Why: an explicit ff-only policy must still fail on divergence rather than + // getting silently reconciled by the merge fallback. + await expect( + dispatcher.callRequest('git.pull', { worktreePath: consumerDir }) + ).rejects.toThrow() + + const parentRefs = execGit(consumerDir, ['log', '-1', '--pretty=%P']).trim().split(/\s+/) + expect(parentRefs).toHaveLength(1) expect(execGit(consumerDir, ['status', '--short'])).toBe('') }, 15_000) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index c6709cd3f2a..510dd60f84b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -44,7 +44,11 @@ import { gitExecMutatesRepository } from '../shared/git-exec-mutation' import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { checkIgnoredPathsOp } from './git-handler-check-ignore' import { resolveRelayPushTarget } from './git-handler-push-target' -import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error' +import { + isNoUpstreamError, + normalizeGitErrorMessage, + runPullWithDivergenceFallback +} from '../shared/git-remote-error' import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status' @@ -1021,29 +1025,33 @@ export class GitHandler { private async pullWithArgs(params: Record, pullArgs: string[]) { this.clearGitMutationReadCaches() const worktreePath = params.worktreePath as string + const runPull = async (effectiveArgs: string[]): Promise => { + if (params.pushTarget !== undefined) { + assertGitPushTargetShape(params.pushTarget) + const pushTarget = params.pushTarget as GitPushTarget + await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) + await this.git( + ['pull', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName], + worktreePath + ) + return + } + const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) + if (upstream && !upstream.isConfiguredUpstream) { + // Why: legacy Orca branches may still track origin/main while pushes + // target origin/. Pull the same effective branch the UI reports. + await this.git( + ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], + worktreePath + ) + return + } + await this.git(['pull', ...effectiveArgs], worktreePath) + } + try { try { - if (params.pushTarget !== undefined) { - assertGitPushTargetShape(params.pushTarget) - const pushTarget = params.pushTarget as GitPushTarget - await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) - await this.git( - ['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName], - worktreePath - ) - return - } - const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) - if (upstream && !upstream.isConfiguredUpstream) { - // Why: legacy Orca branches may still track origin/main while pushes - // target origin/. Pull the same effective branch the UI reports. - await this.git( - ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], - worktreePath - ) - return - } - await this.git(['pull', ...pullArgs], worktreePath) + await runPullWithDivergenceFallback(pullArgs, runPull) } catch (error) { // Why: mirror the local gitPull normalization so SSH users see the same // actionable messages instead of raw git stderr. diff --git a/src/shared/git-remote-error.test.ts b/src/shared/git-remote-error.test.ts index d76d386db11..0a1e6be56e6 100644 --- a/src/shared/git-remote-error.test.ts +++ b/src/shared/git-remote-error.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { formatSubmodulePushFailureDetail, + isDivergentPullReconciliationError, isNoUpstreamError, - normalizeGitErrorMessage + MERGE_RECONCILIATION_PULL_ARGS, + normalizeGitErrorMessage, + pullArgsSpecifyReconciliation, + runPullWithDivergenceFallback } from './git-remote-error' afterEach(() => { @@ -147,3 +151,85 @@ describe('isNoUpstreamError', () => { expect(isNoUpstreamError(error)).toBe(false) }) }) + +describe('isDivergentPullReconciliationError', () => { + it('detects git 2.27+ divergent-branch reconciliation failures', () => { + const error = new Error( + 'Command failed: git pull\n' + + 'hint: You have divergent branches and need to specify how to reconcile them.\n' + + 'fatal: Need to specify how to reconcile divergent branches.' + ) + + expect(isDivergentPullReconciliationError(error)).toBe(true) + }) + + it('does not match a fast-forward-only abort on divergent branches', () => { + const error = new Error( + 'Command failed: git pull\nfatal: Not possible to fast-forward, aborting.' + ) + + expect(isDivergentPullReconciliationError(error)).toBe(false) + }) + + it('returns false for non-Error values', () => { + expect(isDivergentPullReconciliationError('divergent branches')).toBe(false) + }) +}) + +describe('pullArgsSpecifyReconciliation', () => { + it('is false when no strategy flag is present', () => { + expect(pullArgsSpecifyReconciliation([])).toBe(false) + expect(pullArgsSpecifyReconciliation(['origin', 'main'])).toBe(false) + }) + + it('is true for any explicit reconciliation flag', () => { + expect(pullArgsSpecifyReconciliation(['--ff-only'])).toBe(true) + expect(pullArgsSpecifyReconciliation(['--rebase'])).toBe(true) + expect(pullArgsSpecifyReconciliation(['--no-rebase'])).toBe(true) + expect(pullArgsSpecifyReconciliation(['--rebase=interactive'])).toBe(true) + expect(pullArgsSpecifyReconciliation(['-r'])).toBe(true) + }) +}) + +describe('runPullWithDivergenceFallback', () => { + const divergentError = new Error( + 'Command failed: git pull\n' + + 'hint: You have divergent branches and need to specify how to reconcile them.\n' + + 'fatal: Need to specify how to reconcile divergent branches.' + ) + + it('retries with merge reconciliation args on a policy error', async () => { + const calls: string[][] = [] + const runPull = vi.fn(async (effectiveArgs: string[]) => { + calls.push(effectiveArgs) + if (effectiveArgs.length === 0) { + throw divergentError + } + }) + + await runPullWithDivergenceFallback([], runPull) + + expect(calls).toEqual([[], [...MERGE_RECONCILIATION_PULL_ARGS]]) + expect(runPull).toHaveBeenCalledTimes(2) + }) + + it('does not retry when pull args already specify reconciliation', async () => { + const runPull = vi.fn(async () => { + throw divergentError + }) + + await expect(runPullWithDivergenceFallback(['--ff-only'], runPull)).rejects.toBe(divergentError) + expect(runPull).toHaveBeenCalledTimes(1) + expect(runPull).toHaveBeenCalledWith(['--ff-only']) + }) + + it('rethrows non-divergence errors without retrying', async () => { + const otherError = new Error('fatal: Not possible to fast-forward, aborting.') + const runPull = vi.fn(async () => { + throw otherError + }) + + await expect(runPullWithDivergenceFallback([], runPull)).rejects.toBe(otherError) + expect(runPull).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/git-remote-error.ts b/src/shared/git-remote-error.ts index 42cf210292f..d2171f8a6a2 100644 --- a/src/shared/git-remote-error.ts +++ b/src/shared/git-remote-error.ts @@ -20,6 +20,15 @@ const NORMALIZED_SUBMODULE_PUSH_FAILURE_PATTERN = /(?:^|:\s)((?:Submodule '[^'\n]+'|A submodule) (?:has remote changes\. Pull inside the submodule, then try again\.|could not be pushed\. Resolve the submodule push error, then try again\.))(?:$|\s)/i const DIVERGENT_PULL_RECONCILIATION_PATTERN = /Need to specify how to reconcile divergent branches|divergent branches and need to specify how to reconcile them/i +// Why: any of these already pin a reconciliation strategy, so the merge +// fallback must not override an explicit caller/user choice (e.g. --ff-only). +const RECONCILIATION_PULL_ARG_PATTERN = + /^(--rebase|--no-rebase|--ff-only|--ff|--no-ff|--merge|-r)(=|$)/ +// Why: merge is Git's historical pre-2.27 default. Falling back to it lets +// divergent pulls reconcile on fresh hosts that never configured pull.rebase +// or pull.ff, instead of failing outright. `--no-rebase` predates the 2.25 +// baseline, so it is safe across all supported Git binaries. +export const MERGE_RECONCILIATION_PULL_ARGS = ['--no-rebase'] export function stripCredentialsFromMessage(message: string): string { return message.replace(USERPASS_URL_PATTERN, '$1').replace(HTTPS_TOKEN_URL_PATTERN, '$1') @@ -81,6 +90,44 @@ function* iterateLinesFromEnd(value: string): Generator { yield value.slice(0, lineEnd) } +// Why: a fresh host may lack any pull.rebase/pull.ff policy, so Git 2.27+ +// refuses to reconcile divergent branches. Detect that specific failure so the +// caller can retry with an explicit merge instead of surfacing a config error. +export function isDivergentPullReconciliationError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return DIVERGENT_PULL_RECONCILIATION_PATTERN.test(stripCredentialsFromMessage(error.message)) +} + +// Whether the pull already specifies how to reconcile (rebase/merge/ff-only), +// in which case the caller's choice must win over the merge fallback. +export function pullArgsSpecifyReconciliation(pullArgs: string[]): boolean { + return pullArgs.some((arg) => RECONCILIATION_PULL_ARG_PATTERN.test(arg)) +} + +// Why: on hosts with no pull.rebase/pull.ff policy, Git 2.27+ refuses to +// reconcile divergent branches. Retry as a merge (Git's historical default) so +// pulls succeed out of the box; callers that already forced a strategy — or +// users who configured rebase — never reach this fallback. +// Not routed through GitCapabilityCache: this is per-repo config/branch state, +// not a stable host capability, and only fires on an actual divergence error, +// so there is nothing host-scoped to cache. +export async function runPullWithDivergenceFallback( + pullArgs: string[], + runPull: (effectiveArgs: string[]) => Promise +): Promise { + try { + await runPull(pullArgs) + } catch (error) { + if (!pullArgsSpecifyReconciliation(pullArgs) && isDivergentPullReconciliationError(error)) { + await runPull([...MERGE_RECONCILIATION_PULL_ARGS, ...pullArgs]) + return + } + throw error + } +} + export type GitRemoteOperation = 'push' | 'pull' | 'fetch' | 'upstream' export function normalizeGitErrorMessage(error: unknown, operation?: GitRemoteOperation): string { From dc4fb2aa0350f654f42e629d4b94d35ea484d002 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:45:46 -0700 Subject: [PATCH 03/54] fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error (#8418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error A freshly-created session's transcript .jsonl lands on disk seconds to minutes after the process starts. Native chat's one-shot read raced that first flush: a miss became a permanent "No transcript found" error and the live-tail subscription silently degraded to a no-op, so the pane never recovered even after the file appeared. - transcript-reader/read-cache: mark the miss with notFound so callers can tell "not flushed yet" from a real parse/IO error (never cached). - transcript-watch: poll resolve+install (500ms backoff, 5s cap) for the subscription's lifetime instead of returning a dead no-op watcher. - use-native-chat-live-session: retry a notFound read with backoff for up to 60s while staying in the loading state, and let live appends render over a stale initial-read error. Fixes #8401 Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp * fix(native-chat): address CodeRabbit review — ENOENT retryable, content over spinner, blank-id guard, unref poll timer - transcript-reader: an ENOENT after a successful resolve is the same first-flush/rotation race as an unresolved path — mark it notFound. - use-native-chat-live-session: live appends landing mid-retry render instead of the loading state (mirrors the stale-error gate). - transcript-watch: bail out for a blank session id with no explicit file (nothing to resolve-poll), and unref the poll timer so headless serve shutdown is never held open by an unresolvable session. Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp --------- Co-authored-by: kaynan --- .../native-chat/transcript-read-cache.test.ts | 13 ++ src/main/native-chat/transcript-read-cache.ts | 4 +- .../native-chat/transcript-reader.test.ts | 26 ++- src/main/native-chat/transcript-reader.ts | 13 +- src/main/native-chat/transcript-watch.test.ts | 82 +++++++++ src/main/native-chat/transcript-watch.ts | 173 +++++++++++++++--- src/preload/api-types.ts | 6 +- .../use-native-chat-live-session.test.ts | 126 +++++++++++++ .../use-native-chat-live-session.ts | 92 +++++++--- .../e2e/native-chat-first-flush-race.spec.ts | 165 +++++++++++++++++ 10 files changed, 649 insertions(+), 51 deletions(-) create mode 100644 tests/e2e/native-chat-first-flush-race.spec.ts diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index 6f0fc39acb6..0d61f25f48e 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -110,6 +110,19 @@ describe('readNativeChatTranscriptCached', () => { expect('error' in result && result.error).toBeTruthy() }) + // Why: a just-created session's transcript can take up to minutes to exist on + // disk (#8401) — the miss must be marked notFound so watch/renderer callers + // retry instead of settling into a permanent error, and it must never be + // cached (a real error already isn't cached; this locks in the same for a miss). + it('marks a resolve miss as notFound and does not cache it', async () => { + await seedSession('present-2', 1) + const first = await readNativeChatTranscriptCached('claude', 'absent-2') + expect('error' in first && first.notFound).toBe(true) + expect(readSpy).not.toHaveBeenCalled() + const second = await readNativeChatTranscriptCached('claude', 'absent-2') + expect(second).not.toBe(first) + }) + // Why: two worktrees can present the SAME (agent, sessionId) via different // transcript files — e.g. the same session resumed into a second worktree, // which writes a new transcript file. Keying the cache by sessionId let one diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 9f4df5bde87..8943c223dd2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached( ): Promise { const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + // Not cached (see below): a not-yet-flushed transcript should be re-checked + // on the next call, not pinned as a settled miss (#8401). + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } const key = cacheKey(agent, filePath) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index c6c2223c81f..cd87f2bdd59 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -242,19 +242,41 @@ describe('readNativeChatTranscript (codex)', () => { }) describe('readNativeChatTranscript (errors)', () => { - it('returns an error for an unreadable/missing file without throwing', async () => { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path (#8401) — it must stay retry-worthy. + it('marks an ENOENT on a directly-passed path as notFound (vanished after resolve)', async () => { const result = await readNativeChatTranscript('claude', 'sess', { filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) - it('returns an error when no transcript can be resolved', async () => { + it('returns a real read error (no notFound) when the path exists but is unreadable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unreadable-')) + tempRoots.push(root) + // A directory instead of a file fails the read with a non-ENOENT error. + const result = await readNativeChatTranscript('claude', 'sess', { filePath: root }) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBeUndefined() + } + }) + + // Why: a just-created Claude Code session's transcript can take up to minutes + // to exist on disk (#8401) — the miss must be marked retry-worthy so callers + // above (cache, watch, renderer) don't settle into a permanent error. + it('marks an unresolved session as notFound so callers know to retry', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-')) tempRoots.push(root) const result = await readNativeChatTranscript('claude', 'missing', { claudeProjectsDir: join(root, 'empty') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 1dbbd7dd82a..e2ea727c809 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -9,7 +9,11 @@ import { } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' -export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } +export type ReadTranscriptResult = + | { messages: NativeChatMessage[] } + // notFound marks a retry-worthy miss (transcript not flushed to disk yet, + // #8401) as opposed to a real parse/IO error callers surface immediately. + | { error: string; notFound?: true } export type ReadTranscriptOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery (used by tests). */ @@ -30,7 +34,7 @@ export async function readNativeChatTranscript( ): Promise { const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { if (agent === 'claude') { @@ -44,6 +48,11 @@ export async function readNativeChatTranscript( } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path — keep it retry-worthy (#8401). + if ((err as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { error: errorMessage(err), notFound: true } + } return { error: errorMessage(err) } } } diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 55009521a99..4ff621d05bc 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise { return filePath } +// A path inside a fresh temp dir with nothing written yet — simulates a +// just-created session whose agent hasn't flushed its first JSONL line (#8401). +async function pendingFilePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-pending-')) + tempRoots.push(root) + return join(root, 'rollout.jsonl') +} + function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { return `${JSON.stringify({ type: role, @@ -237,3 +245,77 @@ describe('subscribeNativeChatTranscript', () => { sub.unsubscribe() }) }) + +// Regression for #8401: Claude Code (and other agents) can take from ~3s to +// minutes to flush a brand-new session's first JSONL line, so the file +// genuinely doesn't exist when native chat subscribes. Before this fix, +// subscribeNativeChatTranscript returned a permanent no-op the instant the +// file was missing and never recovered once it appeared. +describe('subscribeNativeChatTranscript (resolve-poll for a not-yet-created file, #8401)', () => { + it('keeps retrying resolve+install and tails the file once it is created', async () => { + const filePath = await pendingFilePath() + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Nothing installed yet — the file doesn't exist on disk. + expect(getActiveNativeChatWatcherCount()).toBe(0) + + // The agent flushes its first turn well after subscribe. + await new Promise((resolve) => setTimeout(resolve, 50)) + await writeFile(filePath, claudeLine('u-1', 'user', 'hello')) + + await waitFor(() => seen.some((m) => m.id === 'u-1')) + expect(getActiveNativeChatWatcherCount()).toBe(1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(0) + }) + + it('returns a no-op (no resolve poll) for a blank session id with no explicit file', async () => { + const before = getActiveNativeChatWatcherCount() + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: ' ', + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 10 + }) + + // An unresolvable target must not spin the resolve poll forever. + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + sub.unsubscribe() + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) + + it('unsubscribing during the poll phase leaves no watcher or timer alive', async () => { + const filePath = await pendingFilePath() + const before = getActiveNativeChatWatcherCount() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Unsubscribe while still polling — the file is never created in this test. + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + + // Give any stray timer a chance to fire; it must not install a watcher. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 4bbd934f0eb..1f55c33124a 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -29,6 +29,10 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { filePath?: string /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number } export type NativeChatTranscriptSubscription = { @@ -115,26 +119,17 @@ async function readAppendedMessages( } /** - * Subscribe to live appends on an agent's transcript file. Returns an - * unsubscribe fn that tears the watcher down completely. - * - * Handles file rotation/replacement: when the file shrinks (a new session id - * resolved to a smaller/newer file, or the file was truncated), the offset is - * reset to 0 so the replacement's content is read from the top. + * Install a live-tail watcher on an already-resolved file path. Returns null + * (rather than throwing) when `watch()` fails — e.g. the file vanished between + * resolve and install — so the caller can fall back to the resolve-poll below + * instead of surfacing a hard error. */ -export async function subscribeNativeChatTranscript( - args: SubscribeNativeChatTranscriptArgs -): Promise { - const { agent, sessionId, onAppend, debounceMs } = args - const decode = lineDecoderForAgent(agent) - const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args)) - - if (!filePath || !decode) { - // Nothing watchable — return a no-op teardown so callers can unconditionally - // unsubscribe without null-checks. - return { unsubscribe: () => {} } - } - +function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + onAppend: (messages: NativeChatMessage[]) => void, + debounceMs?: number +): NativeChatTranscriptSubscription | null { // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This // closes the read/subscribe race — a turn appended between the caller's // readSession EOF and the watcher install is still emitted. Re-emitted lines @@ -161,12 +156,12 @@ export async function subscribeNativeChatTranscript( do { pendingReadRequested = false try { - const currentSize = await fileSize(filePath!) + const currentSize = await fileSize(filePath) if (currentSize < offset) { // Rotation/replacement/truncation: re-read from the top. offset = 0 } - const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!) + const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) offset = consumedTo if (!closed && messages.length > 0) { onAppend(messages) @@ -200,8 +195,8 @@ export async function subscribeNativeChatTranscript( try { watcher = watch(filePath, scheduleDrain) } catch { - // File vanished between resolve and watch — return a no-op teardown. - return { unsubscribe: () => {} } + // File vanished between resolve and watch. + return null } activeWatcherCount++ @@ -225,3 +220,135 @@ export async function subscribeNativeChatTranscript( } } } + +/** One resolve+install attempt. Returns null when the file isn't resolvable + * yet, or vanished between resolve and `watch()` — either case is retried by + * the resolve-poll rather than treated as a hard failure. */ +async function attemptInstall( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise { + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!filePath) { + return null + } + return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) +} + +// Why: Claude Code (and other agents) can take from ~3s to minutes to flush a +// brand-new session's first JSONL line (#8401) — resolveSessionFilePath +// genuinely has nothing to find yet. Poll for it instead of going deaf; each +// attempt is a cheap glob, so the cost of polling is negligible next to the +// gap it covers. +const INITIAL_RESOLVE_POLL_MS = 500 +const MAX_RESOLVE_POLL_MS = 5_000 + +/** + * Background retry loop for a transcript that hasn't been resolvable yet. + * Returns a subscription immediately (per subscribeNativeChatTranscript's + * contract); the loop keeps retrying resolve+install until it succeeds or + * unsubscribe() cancels it. + */ +function subscribeViaResolvePoll( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): NativeChatTranscriptSubscription { + let closed = false + let installed: NativeChatTranscriptSubscription | null = null + let pollTimer: ReturnType | null = null + let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + + function scheduleAttempt(): void { + if (closed) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + void runAttempt() + }, delay) + // Why: never hold the event loop open (headless `orca serve` shutdown) for + // a session that may genuinely never resolve. + pollTimer.unref?.() + // Only back off in production; a test-supplied interval stays fixed so + // tests resolve in bounded, predictable time. + if (args.resolvePollIntervalMs === undefined) { + delay = Math.min(delay * 2, MAX_RESOLVE_POLL_MS) + } + } + + async function runAttempt(): Promise { + if (closed) { + return + } + let result: NativeChatTranscriptSubscription | null + try { + result = await attemptInstall(args, decode) + } catch { + // Why: a transient resolve failure (EACCES/EIO during the glob) must not + // kill the poll loop with an unhandled rejection — retry like a miss. + result = null + } + if (closed) { + // unsubscribe() ran while this attempt was in flight. + result?.unsubscribe() + return + } + if (result) { + installed = result + return + } + scheduleAttempt() + } + + scheduleAttempt() + + return { + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + installed?.unsubscribe() + installed = null + } + } +} + +/** + * Subscribe to live appends on an agent's transcript file. Returns an + * unsubscribe fn that tears the watcher down completely. + * + * Handles file rotation/replacement: when the file shrinks (a new session id + * resolved to a smaller/newer file, or the file was truncated), the offset is + * reset to 0 so the replacement's content is read from the top. + * + * When the transcript isn't resolvable yet (a just-created session whose + * agent hasn't flushed its first JSONL line, #8401), returns the subscription + * immediately and keeps retrying resolve+install in the background rather + * than returning a no-op that never recovers. + */ +export async function subscribeNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs +): Promise { + const decode = lineDecoderForAgent(args.agent) + if (!decode) { + // Nothing watchable — return a no-op teardown so callers can unconditionally + // unsubscribe without null-checks. + return { unsubscribe: () => {} } + } + // Why: a blank session id (and no explicit file) can never resolve — bail out + // instead of resolve-polling an unresolvable target forever. + if (!args.filePath && !args.sessionId.trim()) { + return { unsubscribe: () => {} } + } + + const installed = await attemptInstall(args, decode) + if (installed) { + return installed + } + return subscribeViaResolvePoll(args, decode) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5784d5f77c7..599c1a8f81b 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -813,7 +813,11 @@ export type AiVaultApi = { onWindowFocused: (callback: () => void) => () => void } -export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } +// notFound marks a miss caused by the transcript not existing on disk yet +// (retry-worthy), as opposed to a real read/parse error (#8401). +export type NativeChatReadSessionResult = + | { messages: NativeChatMessage[] } + | { error: string; notFound?: true } /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index c2b6e5d7e1b..f60ce34d3c8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -293,3 +293,129 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) }) + +// Regression for #8401: a just-created Claude Code session's transcript can +// take up to minutes to exist on disk, so the first readSession commonly +// misses. Before this fix, the hook settled into a permanent 'error' phase +// on that first miss and never recovered. +describe('useNativeChatLiveSession — notFound retry (#8401)', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-notfound' + const PANE = 'pane-notfound' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(Probe, props)) + await Promise.resolve() + await Promise.resolve() + }) + return root + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + vi.useRealTimers() + }) + + it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession + .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) + .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // First backoff step (1s) fires the second readSession, which resolves. + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + + expect(latest?.status).not.toBe('error') + expect(transport.readSession).toHaveBeenCalledTimes(2) + expect(latest?.messages.map((m) => m.id)).toContain('a-1') + }) + + it('surfaces an error once the ~60s retry window is exhausted', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + await act(async () => { + await vi.advanceTimersByTimeAsync(70_000) + }) + + expect(latest?.status).toBe('error') + expect(latest?.error).toBe('No transcript found') + }) + + it('renders live-appended content instead of loading while the read is still retrying', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // The watcher's first drain lands mid-retry — content must win over the spinner. + await act(async () => { + onAppended?.([assistant('a-early', 'landed during retry')]) + }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((m) => m.id)).toContain('a-early') + }) + + it('renders live-appended content even when the initial read settled into a permanent error', async () => { + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('error') + + await act(async () => { + onAppended?.([assistant('a-late', 'landed late')]) + }) + + expect(latest?.status).not.toBe('error') + expect(latest?.error).toBeUndefined() + expect(latest?.messages.map((m) => m.id)).toContain('a-late') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 8832b6e1966..384ae724769 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -76,6 +76,17 @@ function nextSubscriptionId(): string { return `native-chat-${subscriptionCounter}-${Date.now()}` } +// Why: a brand-new session's transcript can take seconds to minutes to appear +// on disk (#8401), so a `notFound` miss retries — 1s/2s/4s/8s then every 10s — +// until the window below elapses. +const NOTFOUND_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] +const NOTFOUND_RETRY_FIXED_DELAY_MS = 10_000 +const NOTFOUND_RETRY_WINDOW_MS = 60_000 + +function notFoundRetryDelayMs(attempt: number): number { + return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS +} + type ReadState = | { phase: 'loading' } | { phase: 'ready'; messages: NativeChatMessage[] } @@ -161,31 +172,50 @@ export function useNativeChatLiveSession( } let cancelled = false + let retryTimer: ReturnType | null = null + const retryStartedAt = Date.now() + // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing + // above inside a nested function declaration (it's hoisted, so the + // narrowing can't be proven to hold at every call site). + const activeSessionId = sessionId limitRef.current = NATIVE_CHAT_INITIAL_LIMIT setRead({ phase: 'loading' }) replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) - void transport - .readSession(agent, sessionId, limitRef.current, transcriptPath ?? undefined) - .then((result) => { - if (cancelled) { - return - } - if (result && 'error' in result) { - setRead({ phase: 'error', error: result.error }) - return - } - const messages = result?.messages ?? [] - setRead({ phase: 'ready', messages }) - setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) - }) - .catch((err: unknown) => { - if (!cancelled) { - setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) - } - }) + function loadSession(attempt: number): void { + void transport + .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) + .then((result) => { + if (cancelled) { + return + } + if (result && 'error' in result) { + // A not-yet-flushed transcript: stay in 'loading' and retry with + // backoff instead of settling into a permanent error (#8401). + if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) { + retryTimer = setTimeout(() => { + retryTimer = null + loadSession(attempt + 1) + }, notFoundRetryDelayMs(attempt)) + return + } + setRead({ phase: 'error', error: result.error }) + return + } + const messages = result?.messages ?? [] + setRead({ phase: 'ready', messages }) + setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) + }) + .catch((err: unknown) => { + if (!cancelled) { + setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) + } + }) + } + + loadSession(0) const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( @@ -208,6 +238,10 @@ export function useNativeChatLiveSession( return () => { cancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } // Desktop returns a sync unsubscribe fn; the web RPC bridge returns a // Promise instead (and can't deliver streaming callbacks). Calling a // Promise as a function crashed the whole chat view, so resolve it first @@ -304,9 +338,23 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, - loading: read.phase === 'loading', - ...(read.phase === 'error' ? { error: read.error } : {}) + // Why: a watcher append (fix for #8401) can land content while the read is + // still retrying ('loading') or after it settled into 'error' — in both + // cases showing the live content beats a spinner or a stale error, so each + // override only applies while there is nothing appended to render. + loading: read.phase === 'loading' && appended.length === 0, + ...(read.phase === 'error' && appended.length === 0 ? { error: read.error } : {}) }) return { ...session, hasMore, loadingEarlier, loadEarlier } - }, [assembledMessages, read, sessionId, agent, hookState, hasMore, loadingEarlier, loadEarlier]) + }, [ + assembledMessages, + read, + sessionId, + agent, + hookState, + hasMore, + loadingEarlier, + loadEarlier, + appended + ]) } diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts new file mode 100644 index 00000000000..15ec43dc843 --- /dev/null +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import type { GlobalSettings } from '../../src/shared/types' + +const LOADING_TITLE = 'Loading conversation…' +const ERROR_TITLE = 'Could not load conversation' + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +// Why: seeding agentStatusByPaneKey directly (rather than posting a real +// `/hook/claude` event) mirrors the technique agent-session-quit-resume.spec.ts +// uses to stay hermetic — it exercises the identical store → NativeChatView +// path a real Claude Code hook would drive, without an installed CLI. +async function seedClaudeProviderSession( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'e2e first-flush race probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +// Why: toggleTabViewMode keys off the *unified* tab id, which can differ from +// the terminal tab id embedded in paneKey — resolve it the same way +// TerminalPane.tsx does before calling the store action a real toggle/shortcut +// would use. +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +function claudeTranscriptLines(args: { + sessionId: string + userText: string + assistantText: string +}): string { + // Why: distinct timestamps keep the rendered order deterministic (a tie is + // broken by uuid, which would put the assistant turn first). + const userTime = new Date() + const assistantTime = new Date(userTime.getTime() + 2_000) + const lines = [ + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-user`, + timestamp: userTime.toISOString(), + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: args.userText }] } + }, + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-assistant`, + timestamp: assistantTime.toISOString(), + type: 'assistant', + message: { model: 'claude-opus-4', content: [{ type: 'text', text: args.assistantText }] } + } + ] + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` +} + +test.describe('Native chat first-flush transcript race (#8401)', () => { + test('stays in loading (never errors) until a not-yet-flushed transcript appears, then hydrates live', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-first-flush-${randomUUID()}` + + // Why: a real Claude Code session flushes its first JSONL line up to + // minutes after launch (#8401) — this directory intentionally has no file + // yet when the pane resolves its providerSession. + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `native-chat-first-flush-race-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + + try { + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + await expect(orcaPage.getByText(LOADING_TITLE)).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-loading-no-error.png') + }) + + // Why: a short real delay proves the first readSession attempt already + // hit the not-yet-flushed file (returning notFound) and the renderer's + // backoff retry — not a lucky first read — is what picks it up below. + await orcaPage.waitForTimeout(1_500) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + + const userText = 'Explain the native chat first-flush race fix for #8401' + const assistantText = + 'The main process now retries a not-yet-flushed transcript instead of caching a permanent miss.' + writeFileSync(transcriptPath, claudeTranscriptLines({ sessionId, userText, assistantText })) + + await expect(orcaPage.getByText(userText)).toBeVisible({ timeout: 30_000 }) + await expect(orcaPage.getByText(assistantText)).toBeVisible({ timeout: 30_000 }) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '02-hydrated.png') + }) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +}) From fc458f2b300a6c5b5e8bd1966ca99545ea1df6dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:47:17 -0700 Subject: [PATCH 04/54] Remove finished and killed claude subagents from the sidebar roster (#8522) * Remove finished and killed claude subagents from the sidebar roster Finished one-shot subagents stayed in the pane roster as permanent idle rows (dozens per ultracode/workflow session), and a subagent killed without its SubagentStop hook stayed 'working' forever, pinning the pane working. SubagentStop now removes one-shot entries (teammates only idle: they are alive and resumable), and a lead Stop's present background_tasks list is treated as authoritative for non-teammates - unlisted entries are removed. Teammates are identified by their name-embedding agent ids (a-, verified against live hook captures). * Fix Claude subagent roster reconciliation * Drop named workflow lanes that share the teammate id shape Workflow/named one-shot agents report name-embedding lifecycle ids (afinder-C-, agent_type = the label), indistinguishable by shape from resumable teammates - so the roster retained them as idle rows (observed live: a 32-row 12h-old pile). Unlike teammates they ARE listed id-exact as subagent-typed background tasks, including inside their own SubagentStop payload. Use that corroboration to remove them on stop, reclassify task-listed teammate-shaped entries as one-shots, and reap teammate-shaped leftovers when a complete inventory lists no teammate-typed task at all (a teams session always lists its teammates, even idle ones). --- src/shared/agent-hook-listener.test.ts | 78 +++++- src/shared/agent-hook-listener.ts | 21 +- src/shared/claude-subagent-roster.test.ts | 245 +++++++++++++++--- src/shared/claude-subagent-roster.ts | 148 ++++++++--- .../claude-subagent-row-lifecycle.test.ts | 231 +++++++++++++++++ 5 files changed, 641 insertions(+), 82 deletions(-) create mode 100644 src/shared/claude-subagent-row-lifecycle.test.ts diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a593d3bcae9..efafcaa5b06 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -26,6 +26,7 @@ import { clearGrokSessionPathLookupCacheForTests, findGrokChatHistoryBySessionId } from './grok-session-paths' +import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -2309,9 +2310,9 @@ describe('shared agent-hook-listener', () => { const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'r1' }) expect(stopped?.payload.state).toBe('done') - expect(stopped?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'r1', state: 'idle' }) - ]) + // Why: a finished one-shot leaves the sidebar instead of squatting as a + // permanent idle row for the rest of the session. + expect(stopped?.payload.subagents).toBeUndefined() }) it('keeps gating on tracked children when background_tasks is absent (older Claude)', () => { @@ -2408,6 +2409,35 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a teammate whose name differs from its configured agent type', () => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + + // Why: teammate name and agent type are separate Agent-tool inputs; the + // lifecycle id embeds the former while the hook reports the latter. + claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'areviewer-6d3cb5b52120b7bf', + agent_type: 'security-reviewer' + }) + const idled = claudeEvent({ + hook_event_name: 'TeammateIdle', + teammate_name: 'reviewer', + team_name: 'session-x' + }) + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ + id: 'areviewer-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) + ]) + }) + it('scopes subagent rosters per pane', () => { claudeEvent( { hook_event_name: 'SubagentStart', agent_id: 'a1', agent_type: 'general-purpose' }, @@ -2555,7 +2585,7 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) - it('demotes a snapshot-seeded child missing from a present background_tasks list', () => { + it('removes a snapshot-seeded child missing from a present background_tasks list', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ { id: 'a77', state: 'working', startedAt: 1000, agentType: 'general-purpose' } ]) @@ -2569,9 +2599,7 @@ describe('shared agent-hook-listener', () => { ] }) expect(stop?.payload.state).toBe('done') - expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'a77', state: 'idle' }) - ]) + expect(stop?.payload.subagents).toBeUndefined() }) it('keeps a snapshot-seeded child working while background_tasks still lists it', () => { @@ -2589,6 +2617,29 @@ describe('shared agent-hook-listener', () => { ]) }) + it('keeps a live child omitted by the background task snapshot cap', () => { + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'alive-after-cap', + agent_type: 'general-purpose' + }) + const stop = claudeEvent({ + hook_event_name: 'Stop', + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + // Why: the inventory was capped before this id, so omission cannot + // prove the lifecycle-tracked child finished or was killed. + expect(stop?.payload.subagents).toContainEqual( + expect.objectContaining({ id: 'alive-after-cap', state: 'working' }) + ) + expect(stop?.payload.state).toBe('working') + }) + it('does not adopt a known child turn-boundary event as the lead state', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'go' }) claudeEvent({ @@ -2654,7 +2705,12 @@ describe('shared agent-hook-listener', () => { it('seeds the roster from persisted snapshots so a teammate-bearing Stop keeps child rows', () => { seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [ - { id: 'aprobe2-abc', state: 'idle', startedAt: 1000, agentType: 'probe2' } + { + id: 'aprobe2-6d3cb5b52120b7bf', + state: 'idle', + startedAt: 1000, + agentType: 'security-reviewer' + } ]) claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'after restart' }) const stop = claudeEvent({ @@ -2665,7 +2721,11 @@ describe('shared agent-hook-listener', () => { }) expect(stop?.payload.state).toBe('done') expect(stop?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aprobe2-abc', state: 'idle' }) + expect.objectContaining({ + id: 'aprobe2-6d3cb5b52120b7bf', + agentType: 'security-reviewer', + state: 'idle' + }) ]) }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ce3e2f720a4..1e3728eaef7 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -38,8 +38,9 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, + isClaudeTeammateLifecycleId, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -2411,7 +2412,14 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - markClaudeSubagentIdle(roster, agentId) + // Why: SubagentStop carries the session's task inventory; a stopping + // agent listed id-exact as a subagent task is a workflow/named one-shot + // (teammate lifecycle ids never appear there), not a resumable teammate. + const stopTasks = readClaudeBackgroundAgentTasks(hookPayload) + finishClaudeSubagent(roster, agentId, { + listedAsSubagentTask: + stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId) + }) // Why: a blocked child that dies (killed, errored) without another tool // event would otherwise pin its permission/question wait on the pane // forever — nothing else references that agent again. @@ -2449,9 +2457,13 @@ export function seedClaudeSubagentRosterFromSnapshots( startedAt: snapshot.startedAt, agentType: snapshot.agentType, description: snapshot.description, + // Why: teammate name and agent type can differ, but the provider id + // shape survives persistence and keeps the row across restart folds. + ...(isClaudeTeammateLifecycleId(snapshot.id) ? { teammate: true as const } : {}), // Why: the seed can be a phantom (child finished while Orca was down, // its SubagentStop lost). Let a PRESENT background_tasks list that - // omits the id demote it instead of gating the pane 'working' forever. + // omits the id remove it (or demote a teammate) instead of gating the + // pane 'working' forever. backgroundTasksAuthoritative: true }) } @@ -2609,7 +2621,8 @@ function normalizeClaudeEvent( foldClaudeBackgroundTasksIntoRoster( getOrCreateClaudeSubagentRoster(state, paneKey), backgroundTasks.tasks, - Date.now() + Date.now(), + { inventoryComplete: !backgroundTasks.truncated } ) } } diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index e8a4cb6cb1e..99cd9bbb8dc 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -4,8 +4,8 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, + finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, - markClaudeSubagentIdle, markClaudeTeammateIdleByName, readClaudeBackgroundAgentTasks, upsertWorkingClaudeSubagent, @@ -13,55 +13,136 @@ import { } from './claude-subagent-roster' describe('claude-subagent-roster', () => { - it('tracks spawn and stop as working → idle', () => { + it('removes a finished one-shot subagent on stop', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) - markClaudeSubagentIdle(roster, 'a1') - expect(claudeRosterHasWorkingSubagent(roster)).toBe(false) - expect(claudeRosterToSnapshots(roster)).toEqual([ - { - id: 'a1', - state: 'idle', - startedAt: 100, - agentType: 'general-purpose', - description: undefined - } - ]) + // Why: retaining finished one-shots as idle rows piled up dozens of dead + // "Idle - general-purpose" sidebar rows over a long workflow session. + finishClaudeSubagent(roster, 'a1') + expect(roster.size).toBe(0) + expect(claudeRosterToSnapshots(roster)).toBeUndefined() }) - it('re-marks an idle subagent working without resetting startedAt', () => { + it('idles a teammate on stop instead of removing it', () => { const roster: ClaudeSubagentRoster = new Map() - upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'a1', { description: 'round two' }, 200) - expect(roster.get('a1')).toMatchObject({ + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: workflow lanes get name-embedding ids (afinder-C-) like + // teammates, but their SubagentStop payload lists them id-exact as a + // subagent task — real teammate lifecycle ids never appear there. + upsertWorkingClaudeSubagent( + roster, + 'afinder-C-5d713c0781b7f8d2', + { agentType: 'finder-C' }, + 100 + ) + finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true }) + expect(roster.size).toBe(0) + }) + + it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent( + roster, + 'av1-streaming-0b1c2d3e', + { agentType: 'v1-streaming' }, + 100 + ) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'av1-streaming-0b1c2d3e', + agentType: 'v1-streaming', + description: undefined, + running: true, + teammate: false + }, + { + id: 'tteam1', + agentType: undefined, + description: undefined, + running: true, + teammate: true + } + ], + 200 + ) + // A later stop without its own inventory still removes it: the fold + // already proved the id is a task id, not a teammate. + finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e') + expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false) + }) + + it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100) + finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e') + expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' }) + upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150) + + // Why: a teams session lists its teammates (even idle) as teammate-typed + // tasks; an inventory with none proves these name-shaped rows are dead + // workflow lanes, not resumable teammates. + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { + id: 'aunrelated0000001', + agentType: 'general-purpose', + description: undefined, + running: true, + teammate: false + } + ], + 200 + ) + expect(roster.has('acr-triage-1-c5a0588e')).toBe(false) + expect(roster.has('afix-main-11223344')).toBe(false) + expect(roster.has('aunrelated0000001')).toBe(true) + }) + + it('re-marks an idle teammate working without resetting startedAt', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working', startedAt: 100, description: 'round two' }) }) - it('ignores unknown ids on markClaudeSubagentIdle', () => { + it('ignores unknown ids on finishClaudeSubagent', () => { const roster: ClaudeSubagentRoster = new Map() - markClaudeSubagentIdle(roster, 'ghost') + finishClaudeSubagent(roster, 'ghost') expect(roster.size).toBe(0) }) it('caps roster size, evicting the oldest idle entry first', () => { const roster: ClaudeSubagentRoster = new Map() - for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 0) + for (let i = 1; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) } // Why: all working — a new spawn cannot evict live children and is dropped. upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) expect(roster.has('overflow')).toBe(false) - markClaudeSubagentIdle(roster, 'a3') + // Why: only teammates hold idle entries now; an idle teammate is the + // eviction pool when a burst of new spawns hits the cap. + finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) expect(roster.has('replacement')).toBe(true) - expect(roster.has('a3')).toBe(false) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) }) @@ -105,11 +186,21 @@ describe('claude-subagent-roster', () => { expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false) }) + it('marks a background task inventory truncated after the snapshot cap', () => { + const tasks = Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: `a${index}`, + type: 'subagent', + status: 'running' + })) + const result = readClaudeBackgroundAgentTasks({ background_tasks: tasks }) + expect(result.tasks).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(result.truncated).toBe(true) + }) + it('folds background_tasks in without trusting ambiguous entries', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'a1', {}, 100) - markClaudeSubagentIdle(roster, 'a1') - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -137,7 +228,56 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(2) // Why: id-exact matches are one-shot subagents whose run state IS reliable. expect(roster.get('a1')).toMatchObject({ state: 'working', description: 'review loop' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working', agentType: 'reviewer' }) + // Why: the working lifecycle-tracked teammate is not listed by id, but + // omission proves nothing for teammates — it must survive the fold. + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ + state: 'working', + agentType: 'security-reviewer' + }) + }) + + it('removes an id-matched task reported not running', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [{ id: 'a1', agentType: undefined, description: undefined, running: false, teammate: false }], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('removes a killed one-shot missing from a present list', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a running one-shot is always listed id-exact at a lead Stop, so a + // working non-teammate missing from the list is dead (SubagentStop lost); + // keeping it pinned the pane 'working' forever. + upsertWorkingClaudeSubagent(roster, 'akilled0000000001', { agentType: 'general-purpose' }, 100) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.size).toBe(0) + }) + + it('retains an unlisted live child when the background task inventory was truncated', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'alive-after-cap', {}, 100) + const parsed = readClaudeBackgroundAgentTasks({ + background_tasks: Array.from({ length: AGENT_STATUS_MAX_SUBAGENTS + 1 }, (_, index) => ({ + id: index === AGENT_STATUS_MAX_SUBAGENTS ? 'alive-after-cap' : `a${index}`, + type: 'subagent', + status: 'running' + })) + }) + + foldClaudeBackgroundTasksIntoRoster(roster, parsed.tasks, 200, { + inventoryComplete: !parsed.truncated + }) + expect(roster.has('alive-after-cap')).toBe(true) }) it('recreates unmatched running one-shot subagents after a listener restart', () => { @@ -174,17 +314,14 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(0) }) - it('demotes task-id-authoritative entries missing from a present list', () => { + it('removes non-teammate authoritative entries and keeps live teammates on omission', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: seeded/bt-sourced ids ARE task ids; absence from a present list - // proves the task finished. Lifecycle-tracked ids (teammates) prove - // nothing by absence and must keep their state. roster.set('a-phantom', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'ateam-xyz', { agentType: 'reviewer' }, 150) + upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -193,11 +330,33 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-phantom')).toMatchObject({ state: 'idle' }) - expect(roster.get('ateam-xyz')).toMatchObject({ state: 'working' }) + expect(roster.has('a-phantom')).toBe(false) + expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ state: 'working' }) }) - it('marks fold-recreated entries as task-id-authoritative for later folds', () => { + it('demotes a seeded teammate phantom missing from a present list to idle', () => { + const roster: ClaudeSubagentRoster = new Map() + // Why: a teammate seeded from a pre-restart snapshot may be dead, but its + // task id never appears in background_tasks — demote instead of delete so + // a live idle teammate keeps its row while a phantom stops gating the pane. + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + foldClaudeBackgroundTasksIntoRoster( + roster, + [ + { id: 'other', agentType: undefined, description: undefined, running: true, teammate: true } + ], + 200 + ) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true }) + }) + + it('removes fold-recreated entries missing from a later present list', () => { const roster: ClaudeSubagentRoster = new Map() foldClaudeBackgroundTasksIntoRoster( roster, @@ -211,13 +370,21 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a9')).toMatchObject({ state: 'idle' }) + expect(roster.has('a9')).toBe(false) }) - it('stops demoting an entry once live activity re-tracks it', () => { + it('keeps a re-tracked working teammate missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() - roster.set('a-seeded', { state: 'working', startedAt: 100, backgroundTasksAuthoritative: true }) - upsertWorkingClaudeSubagent(roster, 'a-seeded', {}, 150) + roster.set('aprobe1-6d3cb5b5', { + state: 'working', + startedAt: 100, + agentType: 'probe1', + teammate: true, + backgroundTasksAuthoritative: true + }) + // Why: live activity clears the authoritative flag; a busy teammate must + // not be demoted by a Stop that (as always) omits its lifecycle id. + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 150) foldClaudeBackgroundTasksIntoRoster( roster, @@ -226,7 +393,7 @@ describe('claude-subagent-roster', () => { ], 200 ) - expect(roster.get('a-seeded')).toMatchObject({ state: 'working' }) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working' }) }) it('matches teammate ids by name only up to the hyphen-free suffix', () => { diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index b4b4f7ef946..b0b945a76e2 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -14,12 +14,18 @@ export type TrackedClaudeSubagent = { description?: string state: 'working' | 'idle' startedAt: number - /** The id came from background_tasks or a persisted snapshot, not live - * lifecycle events, so a PRESENT list omitting it proves the task is gone - * (a phantom seeded before restart would otherwise gate the pane 'working' - * forever — teams sessions never send an empty list). Cleared once live - * activity re-tracks the id, so a seeded-but-alive teammate is demoted at - * most until its next tool event. */ + /** Known agent-teams teammate: its id embeds its name (`a-`) + * while one-shot ids are hyphen-free (`a`). Teammates are long-lived — + * SubagentStop means "finished a task", not "gone" — and their lifecycle + * ids never appear in background_tasks, so omission proves nothing. */ + teammate?: true + /** The id came from a persisted snapshot or background_tasks, not live + * lifecycle events. Only matters for teammate-shaped seeds now: a PRESENT + * list omitting a seeded-working teammate demotes it to idle so a phantom + * seeded before restart can't gate the pane 'working' forever (teams + * sessions never send an empty list). Cleared once live activity re-tracks + * the id. Non-teammate entries omitted from a present list are removed + * outright regardless of this flag. */ backgroundTasksAuthoritative?: boolean } @@ -36,6 +42,14 @@ export type ClaudeBackgroundAgentTask = { teammate: boolean } +/** Agent-team lifecycle ids are `a-`. The teammate name + * and agent type are independent spawn fields, so the id shape is the only + * reliable discriminator available on SubagentStart/SubagentStop hooks. */ +export function isClaudeTeammateLifecycleId(id: string): boolean { + const separator = id.lastIndexOf('-') + return separator > 1 && id.startsWith('a') && /^[0-9a-f]+$/i.test(id.slice(separator + 1)) +} + export function upsertWorkingClaudeSubagent( roster: ClaudeSubagentRoster, id: string, @@ -45,11 +59,15 @@ export function upsertWorkingClaudeSubagent( if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) { return } + const teammate = isClaudeTeammateLifecycleId(id) const existing = roster.get(id) if (existing) { existing.state = 'working' existing.agentType = fields.agentType ?? existing.agentType existing.description = fields.description ?? existing.description + if (teammate) { + existing.teammate = true + } // Why: live activity proves the lifecycle stream owns this id again; // background_tasks absence must stop demoting it (teammate ids never // appear there). The fold re-tags its own recreations after this call. @@ -63,7 +81,8 @@ export function upsertWorkingClaudeSubagent( state: 'working', startedAt: now, agentType: fields.agentType, - description: fields.description + description: fields.description, + ...(teammate ? { teammate: true as const } : {}) }) } @@ -83,11 +102,31 @@ function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { return true } -export function markClaudeSubagentIdle(roster: ClaudeSubagentRoster, id: string): void { +/** SubagentStop: a finished one-shot subagent leaves the sidebar immediately — + * retaining it as an idle row made long workflow/ultracode sessions pile up + * dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop + * fires each time a teammate finishes a task, not just at shutdown. + * + * Workflow/named one-shots share the teammate id shape (`a diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index e7fb1749ed4..df31f09f8fb 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -32,7 +32,7 @@ import { import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' import type { AppState } from '../../store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key' -import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' import { translate } from '@/i18n/i18n' import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' @@ -709,7 +709,9 @@ function withRepoSectionDisplayLabels(entries: readonly OrderedGroupEntry[]): Or const labelsByPath = getRepoDisplayLabelsByPath(repos) return entries.map(([key, group]) => [ key, - group.repo ? { ...group, label: labelsByPath.get(group.repo.path) ?? group.label } : group + group.repo + ? { ...group, label: labelsByPath.get(getRepoDisplayLabelKey(group.repo)) ?? group.label } + : group ]) } diff --git a/src/renderer/src/lib/repo-display-labels.test.ts b/src/renderer/src/lib/repo-display-labels.test.ts index f0007ffc114..4935f2da1f8 100644 --- a/src/renderer/src/lib/repo-display-labels.test.ts +++ b/src/renderer/src/lib/repo-display-labels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getRepoDisplayLabelsByPath } from './repo-display-labels' +import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from './repo-display-labels' describe('getRepoDisplayLabelsByPath', () => { it('keeps non-colliding repository names basename-only', () => { @@ -8,8 +8,10 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/platform/worker', displayName: 'worker' } ]) - expect(labels.get('/workspace/platform/web')).toBe('web') - expect(labels.get('/workspace/platform/worker')).toBe('worker') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/web' }))).toBe('web') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/worker' }))).toBe( + 'worker' + ) }) it('adds the minimal real parent suffix only for colliding basenames', () => { @@ -19,9 +21,13 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/platform/billing/api', displayName: 'api' } ]) - expect(labels.get('/workspace/platform/web')).toBe('web') - expect(labels.get('/workspace/platform/payments/api')).toBe('payments/api') - expect(labels.get('/workspace/platform/billing/api')).toBe('billing/api') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/web' }))).toBe('web') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/payments/api' }))).toBe( + 'payments/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/platform/billing/api' }))).toBe( + 'billing/api' + ) }) it('expands colliding labels in lockstep without skipping shared segments', () => { @@ -30,8 +36,39 @@ describe('getRepoDisplayLabelsByPath', () => { { path: '/workspace/team2/shared/api', displayName: 'api' } ]) - expect(labels.get('/workspace/team1/shared/api')).toBe('team1/shared/api') - expect(labels.get('/workspace/team2/shared/api')).toBe('team2/shared/api') + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/team1/shared/api' }))).toBe( + 'team1/shared/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: '/workspace/team2/shared/api' }))).toBe( + 'team2/shared/api' + ) + }) + + it('scopes labels by execution host so same-path repos on different hosts do not collide', () => { + // Real SSH folder-repo shape: connectionId set, executionHostId unset — so + // it must fall back to the connection host, not look identical to a local repo. + const localRepo = { path: '/Users/alice', displayName: 'alice' } + const sshRepo = { path: '/Users/alice', displayName: 'alice-prod', connectionId: 'prod-ssh' } + const labels = getRepoDisplayLabelsByPath([localRepo, sshRepo]) + + expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBe('alice') + expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBe('alice-prod') + expect(labels.size).toBe(2) + }) + + it('keeps cross-host repos with identical path AND name as separate entries', () => { + // Hardening: when paths are byte-identical the same-name collision loop runs + // and re-sets each entry, so host scoping must survive that pass too — neither + // host may overwrite the other. Label text can still coincide; that residual is + // disambiguated by the host section/badge, not this map. + const localRepo = { path: '/Users/alice', displayName: 'home' } + const sshRepo = { path: '/Users/alice', displayName: 'home', connectionId: 'prod-ssh' } + const labels = getRepoDisplayLabelsByPath([localRepo, sshRepo]) + + expect(getRepoDisplayLabelKey(localRepo)).not.toBe(getRepoDisplayLabelKey(sshRepo)) + expect(labels.size).toBe(2) + expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBeDefined() + expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBeDefined() }) it('normalizes Windows separators to slash display labels', () => { @@ -40,7 +77,11 @@ describe('getRepoDisplayLabelsByPath', () => { { path: 'C:\\workspace\\billing\\api', displayName: 'api' } ]) - expect(labels.get('C:\\workspace\\payments\\api')).toBe('payments/api') - expect(labels.get('C:\\workspace\\billing\\api')).toBe('billing/api') + expect(labels.get(getRepoDisplayLabelKey({ path: 'C:\\workspace\\payments\\api' }))).toBe( + 'payments/api' + ) + expect(labels.get(getRepoDisplayLabelKey({ path: 'C:\\workspace\\billing\\api' }))).toBe( + 'billing/api' + ) }) }) diff --git a/src/renderer/src/lib/repo-display-labels.ts b/src/renderer/src/lib/repo-display-labels.ts index 995d8d65b36..89b7beeaeb6 100644 --- a/src/renderer/src/lib/repo-display-labels.ts +++ b/src/renderer/src/lib/repo-display-labels.ts @@ -1,6 +1,21 @@ +import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' + type RepoDisplayLabelItem = { path: string displayName: string + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +// Why: two repos can share the same absolute path across hosts (e.g. a local +// /Users/alice and an SSH host's /Users/alice). Keying labels by raw path alone +// lets one repo's label overwrite the other's, so scope the key by execution +// host. getRepoExecutionHostId returns 'local' for local repos and falls back to +// the connectionId (ssh:) for SSH folder-repos that leave executionHostId unset. +export function getRepoDisplayLabelKey( + item: Pick +): string { + return `${getRepoExecutionHostId(item)}::${item.path}` } function normalizePathSegments(path: string): string[] { @@ -29,7 +44,7 @@ export function getRepoDisplayLabelsByPath( for (const item of items) { const displayName = item.displayName || item.path - labels.set(item.path, displayName) + labels.set(getRepoDisplayLabelKey(item), displayName) const colliding = itemsByName.get(displayName) ?? [] colliding.push({ ...item, displayName }) itemsByName.set(displayName, colliding) @@ -49,7 +64,7 @@ export function getRepoDisplayLabelsByPath( nextLabels = collidingItems.map((item) => labelForDepth(item, depth)) } collidingItems.forEach((item, index) => { - labels.set(item.path, nextLabels[index] ?? item.displayName) + labels.set(getRepoDisplayLabelKey(item), nextLabels[index] ?? item.displayName) }) } From 9b092fd129b570837a0e2017607b422bc34c275a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:30:52 -0700 Subject: [PATCH 30/54] fix(orchestration): compare stale-dispatch timestamps with julianday (#8636) getStaleDispatches compared space-format datetime('now') columns (dispatched_at, last_heartbeat_at) against an ISO-Z threshold using raw TEXT ordering. The space (0x20) sorts below 'T' (0x54) at index 10, so every fresh same-UTC-date dispatch/heartbeat was ordered below the threshold and wrongly flagged stale, producing a false stale warning on each coordinator tick. Wrap both column comparisons and both bound threshold params in julianday(), which parses the space and ISO-Z formats as UTC for a correct numeric comparison. Fixes #8452 (cherry picked from commit 1d40312bf2eb83ebbe6079758578e81e97476a26) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Orca From 67fdb1c2b929927b07bdb2bfcdb66dc15f8a6f98 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:32:08 +0200 Subject: [PATCH 31/54] Show agent status in terminal tabs (#8142) * Show agent status in terminal tabs * Resolve terminal tab activity via the canonical worktree-status engine The first pass hand-rolled resolveTerminalTabAgentActivityState, a fourth parallel copy of the pane-iteration/freshness/title-heuristic loop that already lives in smart-attention.ts and worktree-agent-activity-summary.ts. It diverged from every existing surface (novel blocked>waiting split, a phantom 'interrupted' red state the sidebar treats as idle) and re-scanned the global agentStatusByPaneKey map per tab per store write (O(tabs*agents)). Replace it with resolveTerminalTabActivityStatus, which reuses resolveWorktreeStatus (the WorktreeCard resolver: freshness gate, live-PTY liveness, per-leaf title dedup, permission>working>done priority) over a per-tab flag summary bucketed once per store snapshot (O(tabs+agents)). Tabs now speak the same WorktreeStatus vocabulary as the sidebar, so their live states can't disagree with the worktree card. - Map WorktreeStatus -> AgentStateDot: working=spinner, permission=amber, done=check; active/inactive fall through to the agent/shell identity icon. - Parse legacy numeric pane keys too, matching the sidebar summary, so restored/ imported sessions light the tab. - Drop the bespoke resolver + its tests; add focused coverage for the new resolver and the leading-icon component. Co-authored-by: Orca --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../src/components/AgentStateDot.test.ts | 1 + src/renderer/src/components/AgentStateDot.tsx | 4 +- .../src/components/tab-bar/SortableTab.tsx | 97 +++++----- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 85 +++++++++ .../tab-bar/TerminalTabLeadingIcon.tsx | 114 ++++++++++++ .../terminal-tab-activity-status.test.ts | 171 ++++++++++++++++++ .../tab-bar/terminal-tab-activity-status.ts | 159 ++++++++++++++++ 7 files changed, 575 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index e02c5d496ce..2e17d0423df 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -23,6 +23,7 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('motion-reduce:animate-none') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 951e3be36f1..9a4fe792e13 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -30,6 +30,7 @@ export type AgentDotState = // worktree-level permission dot. | 'permission' +/** Return the accessible label shared by every visual agent-state marker. */ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': @@ -57,6 +58,7 @@ type Props = { className?: string } +/** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', @@ -76,7 +78,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]', + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', inner )} /> diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 00beaf35b69..abf4b49cabd 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' -import { ShellIcon } from './shell-icons' -import { AgentIcon } from '@/lib/agent-catalog' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' @@ -11,7 +9,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' -import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' import { useAppStore } from '../../store' import { ACTIVE_TAB_INDICATOR_CLASSES, @@ -26,6 +23,12 @@ import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import { + hasUnreadAgentCompletionForTerminalTab, + isTerminalTabActivityLive, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' type SortableTabProps = { tab: TerminalTab @@ -82,11 +85,26 @@ export default function SortableTab({ isChatView = false, onToggleViewMode }: SortableTabProps): React.JSX.Element { - // Why: subscribe to the per-tab boolean directly so only the tab whose unread - // status actually flipped re-renders. Reading the whole `unreadTerminalTabs` - // map in TabBar would invalidate every SortableTab on every bell event - // because the slice returns a fresh object reference on each mark/clear. - const hasUnreadActivity = useAppStore((s) => s.unreadTerminalTabs[tab.id] === true) + // Why: agent-completion unread is pane-keyed and exists even when the + // experimental generic terminal-attention setting is off. Collapse both + // sources to one per-tab primitive so unrelated tabs do not re-render. + const hasUnreadActivity = useAppStore( + (s) => + s.unreadTerminalTabs[tab.id] === true || + hasUnreadAgentCompletionForTerminalTab(s.unreadAgentCompletionPanes, tab.id) + ) + // Why: the resolver returns a WorktreeStatus primitive, so unrelated agent + // updates can't repaint this tab. The per-tab pane bucketing it reads is + // memoized once per store snapshot, so this stays O(1) per tab per write. + const activityStatus = useAppStore((s) => + resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayout: s.terminalLayoutsByTabId?.[tab.id] + }) + ) const renamingTabId = useAppStore((s) => s.renamingTabId) const setRenamingTabId = useAppStore((s) => s.setRenamingTabId) @@ -118,11 +136,11 @@ export default function SortableTab({ const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [isEditing, setIsEditing] = useState(false) - // Why: single source of truth for the unread-activity visual treatment — - // drives BOTH the amber wash overlay and the bell icon swap below. Kept as - // one derived boolean so the two visual cues can never drift out of sync - // (e.g. showing the bell without the wash, or vice versa). - const showActivityAffordance = hasUnreadActivity && !isEditing + // Why: a live working/needs-input state is newer and more specific than an + // unread event from the prior turn. It owns the icon until the turn ends; + // the unread completion bell then returns if the tab is still unvisited. + const showUnreadActivity = + hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus) const [renameValue, setRenameValue] = useState('') const renameFocusFrameRef = useRef(null) // Why: React's synthetic onBlur fires during the Input's unmount when isEditing flips @@ -236,6 +254,7 @@ export default function SortableTab({ // pass even if the tab-bar render path had silently broken (the same // tautology that let PR #1186's render crash ship past E2E in #1193). data-active={isActive ? 'true' : 'false'} + data-agent-activity-status={activityStatus} {...attributes} {...dragListeners} // Why: on unread activity, tint the whole tab with a subtle amber @@ -284,50 +303,18 @@ export default function SortableTab({ }} > {isActive && } - {showActivityAffordance && ( - // Why: amber wash for unread tabs. Rendered as a real DOM child so - // both drop indicators (::before left / ::after right in - // drop-indicator.ts) stay free for drag-and-drop feedback — a prior - // ::after-based implementation collided with the right-edge drop - // indicator and hid it on unread tabs. pointer-events-none keeps - // clicks reaching the underlying tab handlers. + {showUnreadActivity && ( + // Why: a real DOM child leaves both drop-indicator pseudo-elements + // available and keeps pointer events reaching the tab beneath it. )} - {showActivityAffordance ? ( - // Why: the activity marker sits to the LEFT of the tab title using - // Orca's filled bell glyph (amber-500 with a subtle drop shadow) - // so it matches the worktree-level bell in the sidebar — keeping - // every "needs your attention" surface in Orca consistent. - - - - ) : tabAgent ? ( - // Why: coding-agent tabs should read as Claude/Codex/etc. while the - // harness is running; plain shells keep the generic terminal tile. - - - - ) : ( - // Why: ShellIcon renders a colored brand-style tile for PowerShell, - // CMD, Git Bash, and WSL so Windows users can distinguish shells at a glance. - // On mac/linux (or Windows tabs without a resolved shell) it falls - // back to a matching colored generic-terminal tile — keeping every - // tab's leading glyph in the same visual idiom instead of mixing a - // flat lucide chevron with the brand tiles. Opacity dims the icon - // on inactive tabs to match the existing text treatment without - // desaturating the brand colors beyond recognition. - - - - )} + {isPinned && !isEditing && ( )} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx new file mode 100644 index 00000000000..71508153243 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -0,0 +1,85 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +/** Render one activity status through the production leading-icon component. */ +function renderStatus(status: TerminalTabActivityStatus): string { + return renderToStaticMarkup( + + ) +} + +describe('TerminalTabLeadingIcon', () => { + it('shows a working spinner beside the provider icon', () => { + const markup = renderStatus('working') + + expect(markup).toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-activity-status="working"') + expect(markup).toContain('aria-label="Working"') + expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows completion as an emerald check', () => { + const markup = renderStatus('done') + + expect(markup).toContain('data-agent-activity-status="done"') + expect(markup).toContain('lucide-circle-check') + expect(markup).toContain('text-emerald-500') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('shows a needs-input (permission) state as an amber dot', () => { + const markup = renderStatus('permission') + + expect(markup).toContain('data-agent-activity-status="permission"') + expect(markup).toContain('bg-amber-500') + expect(markup).not.toContain('bg-red-500') + }) + + it('shows no activity glyph for an active shell — just the identity icon', () => { + const markup = renderStatus('active') + + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + expect(markup).toContain('data-agent-icon="codex"') + }) + + it('falls back to the shell icon when a plain tab is inactive', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-shell-icon="generic"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) + + it('keeps the unread bell in the icon slot after an unvisited completion', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-testid="tab-activity-bell"') + expect(markup).toContain('aria-label="Unread agent completion"') + expect(markup).toContain('data-agent-icon="codex"') + expect(markup).not.toContain('data-testid="tab-agent-activity-indicator"') + }) +}) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx new file mode 100644 index 00000000000..5c3b0c7dc24 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -0,0 +1,114 @@ +import { AgentStateDot, type AgentDotState } from '@/components/AgentStateDot' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import type { TerminalTab, TuiAgent } from '../../../../shared/types' +import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' +import { ShellIcon } from './shell-icons' +import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' + +type TerminalTabLeadingIconProps = { + agent: TuiAgent | null + activityStatus: TerminalTabActivityStatus + shell: TerminalTab['shellOverride'] + showUnreadActivity: boolean + isActive: boolean +} + +type TerminalTabAgentIdentityIconProps = { + agent: TuiAgent + isActive: boolean + className?: string +} + +/** + * Map the container status to the shared state-dot vocabulary. `active` and + * `inactive` carry no activity glyph — the tab falls through to its agent or + * shell identity icon instead. Uses the same WorktreeStatus vocabulary as the + * sidebar so live states read identically (tabs intentionally omit the card's + * retained-done promotion, so a stale green check can differ after cleanup). + */ +function activityDotState(status: TerminalTabActivityStatus): AgentDotState | null { + switch (status) { + case 'working': + return 'working' + case 'permission': + return 'permission' + case 'done': + return 'done' + default: + return null + } +} + +/** Keep the provider glyph treatment identical across every terminal-tab state. */ +function TerminalTabAgentIdentityIcon({ + agent, + isActive, + className +}: TerminalTabAgentIdentityIconProps): React.JSX.Element { + return ( + + + + ) +} + +/** Render a terminal tab's current state without hiding its agent or shell identity. */ +export function TerminalTabLeadingIcon({ + agent, + activityStatus, + shell, + showUnreadActivity, + isActive +}: TerminalTabLeadingIconProps): React.JSX.Element { + if (showUnreadActivity) { + return ( + + + {agent ? : null} + + ) + } + + const dotState = activityDotState(activityStatus) + if (dotState) { + return ( + + + {/* Why: status and identity answer different questions. Keep the agent + logo beside the state glyph so parallel tabs remain scannable. */} + {agent ? : null} + + ) + } + + if (agent) { + return ( + + ) + } + + // Why: ShellIcon renders a colored brand-style tile for PowerShell, CMD, + // Git Bash, and WSL while retaining the generic terminal fallback elsewhere. + return ( + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts new file mode 100644 index 00000000000..9dc16e8f70b --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import { + hasUnreadAgentCompletionForTerminalTab, + resetTerminalTabActivityFlagsCacheForTest, + resolveTerminalTabActivityStatus +} from './terminal-tab-activity-status' + +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const NOW = 10_000 + +const TAB: Pick = { id: TAB_ID, title: 'Codex' } + +/** Build a canonical pane-status fixture for one tab leaf. */ +function entry( + leafId: string, + state: AgentStatusEntry['state'], + overrides: Partial = {} +): AgentStatusEntry { + const paneKey = `${TAB_ID}:${leafId}` + return { + paneKey, + state, + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + agentType: 'codex', + ...overrides + } +} + +/** One live PTY for the tab so title/liveness gates pass. */ +const LIVE_PTY = { [TAB_ID]: ['pty-1'] } + +beforeEach(() => { + resetTerminalTabActivityFlagsCacheForTest() + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) + +afterEach(() => { + vi.useRealTimers() + resetTerminalTabActivityFlagsCacheForTest() +}) + +describe('resolveTerminalTabActivityStatus', () => { + it('reports a fresh hook working state', () => { + const working = entry(FIRST_LEAF_ID, 'working') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('lets a needs-input pane outrank a working sibling', () => { + const working = entry(FIRST_LEAF_ID, 'working') + const waiting = entry(SECOND_LEAF_ID, 'waiting') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { + [working.paneKey]: working, + [waiting.paneKey]: waiting + }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reports a completed turn as done', () => { + const done = entry(FIRST_LEAF_ID, 'done') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [done.paneKey]: done }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('treats an interrupted done as done, matching the worktree card', () => { + const interrupted = entry(FIRST_LEAF_ID, 'done', { interrupted: true }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [interrupted.paneKey]: interrupted }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('done') + }) + + it('falls back to a live working title when hook status is stale', () => { + const stale = entry(FIRST_LEAF_ID, 'done', { updatedAt: 0 }) + vi.setSystemTime(31 * 60 * 1000) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + agentStatusByPaneKey: { [stale.paneKey]: stale }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('does not treat a preserved title on a sleeping tab as activity', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex working' }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, + ptyIdsByTabId: { [TAB_ID]: [] } + }) + ).toBe('inactive') + }) + + it('reads a needs-input hook as permission', () => { + const blocked = entry(FIRST_LEAF_ID, 'blocked') + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [blocked.paneKey]: blocked }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('permission') + }) + + it('reads a legacy numeric pane key, matching the sidebar summary', () => { + const working = entry(FIRST_LEAF_ID, 'working', { paneKey: `${TAB_ID}:3` }) + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey: { [working.paneKey]: working }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + }) + + it('reports a live shell with no agent as active (no activity glyph)', () => { + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'zsh' }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) +}) + +describe('hasUnreadAgentCompletionForTerminalTab', () => { + it('matches unread completion panes to their owning tab', () => { + expect( + hasUnreadAgentCompletionForTerminalTab( + { + [`${TAB_ID}:${FIRST_LEAF_ID}`]: true, + [`tab-2:${SECOND_LEAF_ID}`]: true + }, + TAB_ID + ) + ).toBe(true) + }) + + it('ignores completion panes owned by other tabs', () => { + expect( + hasUnreadAgentCompletionForTerminalTab({ [`tab-2:${SECOND_LEAF_ID}`]: true }, TAB_ID) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts new file mode 100644 index 00000000000..2e51d42e1d5 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -0,0 +1,159 @@ +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { resolveWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' + +// Why: a terminal tab is a container of panes, exactly like a worktree card is +// a container of tabs. Reuse the WorktreeCard status vocabulary and resolver so +// the tab's live states resolve identically to the sidebar (tabs intentionally +// skip the card's retained-done promotion — see resolveTerminalTabActivityStatus). +export type TerminalTabActivityStatus = WorktreeStatus + +// Per-tab live-hook flags, mirroring applyLiveAgentState in +// worktree-agent-activity-summary.ts. blocked/waiting collapse to permission, +// matching every other status surface in the app. +type TerminalTabActivityFlags = { + hasPermission: boolean + hasLiveWorking: boolean + hasLiveDone: boolean + paneIds: Set +} + +type FlagsCache = { + agentStatusByPaneKey: Record | undefined + flagsByTabId: Map +} + +// Why: Zustand reruns every tab's selector on each store write. Bucketing the +// full pane-status map by tab once per snapshot keeps the cost O(agents + tabs) +// instead of O(agents * tabs) — the same memo strategy the sidebar summaries +// use (worktree-agent-activity-summary.ts / worktree-agent-row-selectors.ts). +let flagsCache: FlagsCache | null = null + +function getTerminalTabActivityFlags( + agentStatusByPaneKey: Record | undefined +): Map { + if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) { + return flagsCache.flagsByTabId + } + + const flagsByTabId = new Map() + const now = Date.now() + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey ?? {})) { + const identity = parseAgentStatusPaneKey(entry.paneKey || paneKey) + // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane + // must not keep a tab spinning. Same freshness gate as the sidebar. + if (!identity || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + continue + } + + let flags = flagsByTabId.get(identity.tabId) + if (!flags) { + flags = { + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: false, + paneIds: new Set() + } + flagsByTabId.set(identity.tabId, flags) + } + flags.paneIds.add(identity.paneId) + if (entry.state === 'blocked' || entry.state === 'waiting') { + flags.hasPermission = true + } else if (entry.state === 'working') { + flags.hasLiveWorking = true + } else if (entry.state === 'done') { + // Why: an interrupted `done` still reads as completed here, matching the + // WorktreeCard dot (resolveWorktreeStatus has no interrupted state); only + // the smart-sort ordering treats interrupts as idle. + flags.hasLiveDone = true + } + } + + flagsCache = { agentStatusByPaneKey, flagsByTabId } + return flagsByTabId +} + +// Why: mirror the sidebar summary's parse — live entries on restored/imported +// sessions can still carry pre-UUID numeric pane keys. Keep the numeric pane id +// so the title-heuristic dedup in resolveWorktreeStatus can still match them. +function parseAgentStatusPaneKey(paneKey: string): { tabId: string; paneId: string } | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + return { tabId: parsed.tabId, paneId: parsed.leafId } + } + const legacy = parseLegacyNumericPaneKey(paneKey) + return legacy ? { tabId: legacy.tabId, paneId: legacy.numericPaneId } : null +} + +const EMPTY_PANE_IDS: ReadonlySet = new Set() + +type TerminalTabActivityInput = { + tab: Pick + agentStatusByPaneKey?: Record + runtimePaneTitlesByTabId?: Record> + ptyIdsByTabId?: Record + terminalLayout?: TerminalLayoutSnapshot +} + +/** + * Resolve a terminal tab's status glyph through the canonical WorktreeCard + * resolver. Fresh hook state is authoritative per pane; hookless-but-live panes + * fall back to the same title heuristic used by the sidebar and smart sort. + * Returns a `WorktreeStatus` primitive so the tab re-renders only when it flips. + */ +export function resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayout +}: TerminalTabActivityInput): TerminalTabActivityStatus { + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id) + return resolveWorktreeStatus({ + tabs: [tab], + browserTabs: [], + ptyIdsByTabId: ptyIdsByTabId ?? {}, + runtimePaneTitlesByTabId: runtimePaneTitlesByTabId ?? {}, + agentStatusPaneIdsByTabId: { [tab.id]: flags?.paneIds ?? EMPTY_PANE_IDS }, + terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, + hasPermission: flags?.hasPermission ?? false, + hasLiveWorking: flags?.hasLiveWorking ?? false, + hasLiveDone: flags?.hasLiveDone ?? false, + // Why: retained/orchestration promotions are worktree-aggregate concerns; + // a tab reflects its own live panes and title only. + hasRetainedDone: false + }) +} + +/** True while the tab shows a live in-turn signal (spinner or needs-input). */ +export function isTerminalTabActivityLive(status: TerminalTabActivityStatus): boolean { + return status === 'working' || status === 'permission' +} + +/** Match pane-level unread completion markers to their owning terminal tab. */ +export function hasUnreadAgentCompletionForTerminalTab( + unreadAgentCompletionPanes: Record | undefined, + tabId: string +): boolean { + for (const paneKey of Object.keys(unreadAgentCompletionPanes ?? {})) { + // paneKey is `${tabId}:${leafId}` and tab ids never contain ":", so the + // prefix up to the first ":" is the owning tab id (see + // selectFloatingWorkspaceHasUnread). Prefix-match to keep legacy keys. + const separatorIndex = paneKey.indexOf(':') + const owningTabId = separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) + if (owningTabId === tabId) { + return true + } + } + return false +} + +/** Test-only: clear the memoized per-tab flag cache between cases. */ +export function resetTerminalTabActivityFlagsCacheForTest(): void { + flagsCache = null +} From 1c6098c214505a9a8786308bca0dfae970b21134 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:52:09 -0700 Subject: [PATCH 32/54] fix(terminal-tabs): invalidate activity-status cache on freshness update (#8641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store bumps agentStatusEpoch at the 30m stale boundary without replacing the agentStatusByPaneKey map. Keying the flag cache on map reference alone kept serving stale flags (old timestamp, abandoned spinning), while the sidebar — which keys on agentStatusEpoch — correctly de-spun. Invalidate on either changing. Co-authored-by: Orca --- .../src/components/tab-bar/SortableTab.tsx | 1 + .../terminal-tab-activity-status.test.ts | 27 +++++++++++++++++++ .../tab-bar/terminal-tab-activity-status.ts | 23 +++++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index abf4b49cabd..0b694fb6fd4 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -100,6 +100,7 @@ export default function SortableTab({ resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey: s.agentStatusByPaneKey, + agentStatusEpoch: s.agentStatusEpoch, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, ptyIdsByTabId: s.ptyIdsByTabId, terminalLayout: s.terminalLayoutsByTabId?.[tab.id] diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 9dc16e8f70b..2f6cbcadcd8 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -108,6 +108,33 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it('de-spins a stale working tab on an epoch bump without a new map reference', () => { + // Why: the freshness scheduler bumps agentStatusEpoch (not the map ref) at + // the 30m stale boundary. The flag cache must invalidate on that bump, or an + // abandoned tab keeps spinning while the sidebar (epoch-keyed) de-spins. + const working = entry(FIRST_LEAF_ID, 'working') + const agentStatusByPaneKey = { [working.paneKey]: working } + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey, + agentStatusEpoch: 0, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('working') + + vi.setSystemTime(31 * 60 * 1000) + // Same map reference, bumped epoch — the entry is now stale. + expect( + resolveTerminalTabActivityStatus({ + tab: TAB, + agentStatusByPaneKey, + agentStatusEpoch: 1, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) + it('does not treat a preserved title on a sleeping tab as activity', () => { expect( resolveTerminalTabActivityStatus({ diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 2e51d42e1d5..211aae2c3b2 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -25,6 +25,7 @@ type TerminalTabActivityFlags = { type FlagsCache = { agentStatusByPaneKey: Record | undefined + agentStatusEpoch: number | undefined flagsByTabId: Map } @@ -35,9 +36,19 @@ type FlagsCache = { let flagsCache: FlagsCache | null = null function getTerminalTabActivityFlags( - agentStatusByPaneKey: Record | undefined + agentStatusByPaneKey: Record | undefined, + agentStatusEpoch: number | undefined ): Map { - if (flagsCache && flagsCache.agentStatusByPaneKey === agentStatusByPaneKey) { + // Why: freshness is time-based, so the store bumps agentStatusEpoch without + // replacing the map at the 30m stale boundary (createFreshnessScheduler). + // Keying on the map reference alone would keep serving flags computed at the + // old `now`, spinning an abandoned tab forever while the sidebar — which keys + // on agentStatusEpoch — correctly de-spins. Invalidate on either changing. + if ( + flagsCache && + flagsCache.agentStatusByPaneKey === agentStatusByPaneKey && + flagsCache.agentStatusEpoch === agentStatusEpoch + ) { return flagsCache.flagsByTabId } @@ -74,7 +85,7 @@ function getTerminalTabActivityFlags( } } - flagsCache = { agentStatusByPaneKey, flagsByTabId } + flagsCache = { agentStatusByPaneKey, agentStatusEpoch, flagsByTabId } return flagsByTabId } @@ -95,6 +106,9 @@ const EMPTY_PANE_IDS: ReadonlySet = new Set() type TerminalTabActivityInput = { tab: Pick agentStatusByPaneKey?: Record + // Why: the store bumps this at the 30m stale boundary without replacing the + // pane-status map; it is the flag cache's invalidation key (see above). + agentStatusEpoch?: number runtimePaneTitlesByTabId?: Record> ptyIdsByTabId?: Record terminalLayout?: TerminalLayoutSnapshot @@ -109,11 +123,12 @@ type TerminalTabActivityInput = { export function resolveTerminalTabActivityStatus({ tab, agentStatusByPaneKey, + agentStatusEpoch, runtimePaneTitlesByTabId, ptyIdsByTabId, terminalLayout }: TerminalTabActivityInput): TerminalTabActivityStatus { - const flags = getTerminalTabActivityFlags(agentStatusByPaneKey).get(tab.id) + const flags = getTerminalTabActivityFlags(agentStatusByPaneKey, agentStatusEpoch).get(tab.id) return resolveWorktreeStatus({ tabs: [tab], browserTabs: [], From c408a3d85268181f22be9c84dee2de32fe97c3b7 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:56:36 -0700 Subject: [PATCH 33/54] feat(mobile): show usage reset countdown on accounts screen (#7954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): show usage reset countdown on accounts screen Surface the rate-limit reset time ("5h resets in 3h 54m · 7d resets in 6d 7h") under the usage bars on the mobile accounts screen, matching the desktop status-bar tooltip copy. The resetsAt timestamps already arrive in the accounts.subscribe snapshot; this only adds the presentation. Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * docs(mobile): JSDoc for new usage reset selectors Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * refactor(mobile): per-bar reset countdown instead of combined line Drop the redundant "5h/7d" prefixes — each countdown now renders under its own bar ("Resets in 3h 54m"), matching the desktop tooltip copy exactly. Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ * Extract shared reset-countdown formatter for desktop and mobile - Move duration/countdown formatting out of tooltip.tsx into src/shared/rate-limit-reset-format.ts so mobile's account-usage-state can reuse it instead of a duplicated copy (with tests). - Re-export formatResetCountdown from tooltip.tsx to avoid touching existing import paths. - Resend the pairing deep link once more in start-emulator.mjs since the first can arrive before the Expo app's JS router is ready. --------- Co-authored-by: kaynan Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- mobile/app/h/[hostId]/accounts.tsx | 13 ++++ mobile/scripts/start-emulator.mjs | 5 ++ mobile/src/components/AccountUsage.tsx | 64 +++++++++++++------ .../components/account-usage-state.test.ts | 60 +++++++++++++++++ mobile/src/components/account-usage-state.ts | 23 +++++++ .../src/components/status-bar/tooltip.tsx | 31 +++------ src/shared/rate-limit-reset-format.test.ts | 30 +++++++++ src/shared/rate-limit-reset-format.ts | 32 ++++++++++ 8 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 src/shared/rate-limit-reset-format.test.ts create mode 100644 src/shared/rate-limit-reset-format.ts diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abfa8..652dfffee75 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -23,6 +23,7 @@ import { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' @@ -40,6 +41,14 @@ export default function AccountsScreen() { const [refreshing, setRefreshing] = useState(false) const [busyAccountId, setBusyAccountId] = useState(null) + // Why: the reset countdown must stay fresh while the screen sits open — + // snapshot pushes only arrive when the desktop's rate-limit poll completes. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(id) + }, []) + useEffect(() => { if (!hostId) { return @@ -163,12 +172,14 @@ export default function AccountsScreen() { usedPercent={activeSessionBar.usedPercent} unavailable={activeSessionBar.unavailable} loading={activeSessionBar.loading} + resetText={getWindowResetLabel(activeUsage, 'session', now)} /> ) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} /> {usage?.error ? ( diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45c0..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the first deep link can arrive while the freshly opened Expo app is + // still mounting, so resend it once the JS router is ready to receive URLs. + await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) + await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the mobile app intentionally asks for a trust confirmation before // saving a host. This lands on the Pair button on current iPhone simulators. await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e607487..713a2dd8a46 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -17,6 +17,7 @@ export { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage } from './account-usage-state' @@ -28,12 +29,14 @@ export function UsageBar({ label, usedPercent, unavailable, - loading + loading, + resetText }: { label: string usedPercent: number | null unavailable: boolean loading?: boolean + resetText?: string | null }) { // Why: round then clamp so bar width, color, and label share one value (desktop parity). const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent))) @@ -47,34 +50,48 @@ export function UsageBar({ ? colors.statusAmber : colors.statusGreen return ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } const styles = StyleSheet.create({ + usageBarColumn: { + flex: 1, + gap: 2 + }, usageBar: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, - flex: 1 + gap: spacing.xs }, usageLabel: { fontSize: typography.metaSize, @@ -100,5 +117,12 @@ const styles = StyleSheet.create({ }, usageSpinner: { width: 36 + }, + // Why: indented past the window label so the countdown aligns with the + // start of the track above it. + usageResetText: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginLeft: 22 + spacing.xs } }) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f567143584e..3e128b7b099 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage, type AccountsSnapshot, @@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => { }) }) +describe('getWindowResetLabel', () => { + const now = 1_700_000_000_000 + const min = 60_000 + const hour = 60 * min + const day = 24 * hour + + function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] { + return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null } + } + + it('is null when there are no limits or the window has no reset timestamp', () => { + expect(getWindowResetLabel(null, 'session', now)).toBe(null) + expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null) + expect( + getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now) + ).toBe(null) + }) + + it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now) + ).toBe('Resets in 47m') + expect( + getWindowResetLabel( + makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }), + 'session', + now + ) + ).toBe('Resets in 3h 54m') + expect( + getWindowResetLabel( + makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }), + 'weekly', + now + ) + ).toBe('Resets in 6d 7h') + }) + + it('formats exact hours and exact days without a zero remainder', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now) + ).toBe('Resets in 2h') + expect( + getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now) + ).toBe('Resets in 7d') + }) + + it('reports "Resets now" for a reset timestamp in the past', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now) + ).toBe('Resets now') + }) + + it('reads the requested window only', () => { + const limits = makeLimits({ session: makeWindow(now + hour) }) + expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null) + }) +}) + describe('getUsageBarState', () => { it('keeps stale window data visible during a transient error', () => { const bar = getUsageBarState( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731cd0..fa5b4a516b3 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -5,6 +5,8 @@ // Pure state/selectors live here (no React Native imports) so they can be // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. +import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' + export type RateLimitWindow = { usedPercent: number windowMinutes: number @@ -117,6 +119,27 @@ export function getUsageBarState( } } +/** + * Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now", + * or null when the window has no reset timestamp (so the UI degrades to + * today's bars-only layout). + * + * Why: shares formatResetCountdown with the desktop status-bar tooltip so the + * copy stays identical across surfaces. `now` is a parameter so the function + * stays pure and unit-testable. + */ +export function getWindowResetLabel( + limits: ProviderRateLimits | null, + windowKey: 'session' | 'weekly', + now: number +): string | null { + const resetsAt = limits?.[windowKey]?.resetsAt + if (resetsAt == null) { + return null + } + return formatResetCountdown(resetsAt - now) +} + // Why: the usage UI must render for the system-default login, not only for // Orca-managed accounts. Show a provider when it has at least one managed // account OR active rate-limit data for the system-default target. diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c2fde5de91a..849dc7beda3 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,4 +1,8 @@ import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { + formatResetCountdown, + formatResetDuration +} from '../../../../shared/rate-limit-reset-format' import { AgentIcon } from '@/lib/agent-catalog' import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { translate } from '@/i18n/i18n' @@ -39,28 +43,9 @@ export function formatTimeAgo(ts: number): string { return `${hours}h ago` } -function formatDuration(ms: number): string { - if (ms <= 0) { - return 'now' - } - const totalMins = Math.floor(ms / 60_000) - if (totalMins < 60) { - return `${totalMins}m` - } - const hours = Math.floor(totalMins / 60) - const mins = totalMins % 60 - if (hours >= 24) { - const days = Math.floor(hours / 24) - const remHours = hours % 24 - return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` - } - return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` -} - -export function formatResetCountdown(ms: number): string { - const duration = formatDuration(ms) - return duration === 'now' ? 'Resets now' : `Resets in ${duration}` -} +// Re-export so existing tooltip consumers/tests keep their import path; the +// implementation is shared with mobile in src/shared/rate-limit-reset-format. +export { formatResetCountdown } export function formatResetCreditExpiry( expiresAt: number | null | undefined, @@ -69,7 +54,7 @@ export function formatResetCreditExpiry( if (!expiresAt) { return null } - const duration = formatDuration(expiresAt - Date.now()) + const duration = formatResetDuration(expiresAt - Date.now()) if (duration === 'now') { return count > 1 ? translate('auto.components.status.bar.tooltip.7ec6e030a0', 'Next expires now') diff --git a/src/shared/rate-limit-reset-format.test.ts b/src/shared/rate-limit-reset-format.test.ts new file mode 100644 index 00000000000..1598c4c420f --- /dev/null +++ b/src/shared/rate-limit-reset-format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { formatResetCountdown, formatResetDuration } from './rate-limit-reset-format' + +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +describe('formatResetDuration', () => { + it('returns "now" for non-positive deltas', () => { + expect(formatResetDuration(0)).toBe('now') + expect(formatResetDuration(-1)).toBe('now') + }) + + it('floors to whole units and drops zero remainders', () => { + expect(formatResetDuration(47 * MIN)).toBe('47m') + expect(formatResetDuration(3 * HOUR + 54 * MIN)).toBe('3h 54m') + expect(formatResetDuration(2 * HOUR)).toBe('2h') + expect(formatResetDuration(6 * DAY + 7 * HOUR)).toBe('6d 7h') + expect(formatResetDuration(7 * DAY)).toBe('7d') + }) +}) + +describe('formatResetCountdown', () => { + it('prefixes the duration or reports "Resets now"', () => { + expect(formatResetCountdown(0)).toBe('Resets now') + expect(formatResetCountdown(3 * HOUR + 54 * MIN)).toBe('Resets in 3h 54m') + expect(formatResetCountdown(6 * DAY + 7 * HOUR)).toBe('Resets in 6d 7h') + }) +}) diff --git a/src/shared/rate-limit-reset-format.ts b/src/shared/rate-limit-reset-format.ts new file mode 100644 index 00000000000..2c1b48809c7 --- /dev/null +++ b/src/shared/rate-limit-reset-format.ts @@ -0,0 +1,32 @@ +// Why: shared by the desktop status-bar tooltip and the mobile accounts screen +// so rate-limit reset/expiry countdown copy stays identical across surfaces. +// Pure (no platform imports) — safe to bundle in both the renderer and mobile. + +/** + * Compact human duration for a rate-limit window, flooring to whole units: + * "47m", "3h 54m", "6d 7h". Returns "now" for a non-positive delta so callers + * can special-case the "already reset" copy. + */ +export function formatResetDuration(ms: number): string { + if (ms <= 0) { + return 'now' + } + const totalMins = Math.floor(ms / 60_000) + if (totalMins < 60) { + return `${totalMins}m` + } + const hours = Math.floor(totalMins / 60) + const mins = totalMins % 60 + if (hours >= 24) { + const days = Math.floor(hours / 24) + const remHours = hours % 24 + return remHours > 0 ? `${days}d ${remHours}h` : `${days}d` + } + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` +} + +/** "Resets in 3h 54m" / "Resets now" for a window's time-until-reset (ms). */ +export function formatResetCountdown(ms: number): string { + const duration = formatResetDuration(ms) + return duration === 'now' ? 'Resets now' : `Resets in ${duration}` +} From 302b97029a60de1c16595a144119513f2ca723e3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:58:21 -0700 Subject: [PATCH 34/54] P2 windows cli hardening (#8638) * fix(cli): harden Windows launcher transports * Fix csc.exe compile failures on space-bearing Windows install paths - Legacy csc.exe mangles absolute paths containing spaces, so the compile step now cd's into the bin directory and passes bare file names for /out and the source file instead of full paths --- .github/workflows/computer-e2e.yml | 6 + config/scripts/build-windows-cli-launcher.mjs | 6 +- .../build-windows-cli-launcher.test.mjs | 18 ++ src/main/cli/cli-installer.test.ts | 18 +- src/main/cli/cli-installer.ts | 4 +- src/main/ssh/ssh-relay-session.test.ts | 20 +- src/main/ssh/ssh-relay-session.ts | 81 ++---- src/main/ssh/ssh-remote-cli-launcher.test.ts | 143 +++++++++++ src/main/ssh/ssh-remote-cli-launcher.ts | 237 ++++++++++++++++++ 9 files changed, 451 insertions(+), 82 deletions(-) create mode 100644 src/main/ssh/ssh-remote-cli-launcher.test.ts create mode 100644 src/main/ssh/ssh-remote-cli-launcher.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 235868f6155..76595f338a6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,8 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -22,12 +24,15 @@ on: - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' + - 'native/windows-cli-launcher/**' - 'skills/computer-use/SKILL.md' - 'src/cli/**' - 'src/main/computer/**' - 'src/main/runtime/rpc/dispatcher.ts' - 'src/main/runtime/rpc/errors.ts' - 'src/main/runtime/rpc/methods/computer*.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.test.ts' - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-mac.e2e.ts' @@ -73,6 +78,7 @@ jobs: - run: >- pnpm vitest run 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/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index c8478f3aee0..6f9b86bdb4b 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' if (process.platform !== 'win32') { - process.exit(0) + // Why: electron-builder treats a skipped native build like success and can + // continue toward a Windows package whose declared orca.exe does not exist. + throw new Error( + 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' + ) } const repoRoot = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e5bcbaa557b..4af5a68dd5a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -5,9 +5,27 @@ import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' const itWindows = process.platform === 'win32' ? it : it.skip +const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') describe('Windows CLI launcher', () => { + itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { + const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher ')) + try { + const result = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')], + { cwd: projectRoot, encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows CLI launcher') + expect(result.stderr).toContain('Windows host') + } finally { + rmSync(outputRoot, { recursive: true, force: true }) + } + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 596cb8bbfc8..a42805313c0 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1174,10 +1174,10 @@ describe('CliInstaller', () => { } ) - it('resolves packaged Windows command path to resources/bin/orca.exe', async () => { + it('resolves custom-install packaged Windows command path from resourcesPath', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(fixture.root, 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') await mkdir(join(resourcesPath, 'bin'), { recursive: true }) await writeFile(join(resourcesPath, 'bin', 'orca.exe'), 'native launcher', 'utf8') @@ -1187,22 +1187,20 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => null, userPathWriter: async () => {} }) const status = await installer.getStatus() - expect(status.commandPath).toBe( - join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') - ) + expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') const bundledContent = 'native launcher' await mkdir(dirname(bundledLauncher), { recursive: true }) @@ -1215,7 +1213,7 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => userPath, userPathWriter: async (value) => { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index da8a3cc076a..c5f1286126b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -356,7 +356,9 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') + // Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged + // resources directory is the authoritative native launcher location. + return getBundledLauncherPath(this.platform, this.resourcesPath) } return null diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 82cbfaee42c..da490ae7be0 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -424,7 +424,7 @@ describe('SshRelaySession', () => { expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => { + it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { writeFile: vi.fn().mockResolvedValue(undefined) @@ -447,20 +447,20 @@ describe('SshRelaySession', () => { await session.establish(mockConn) - expect(execCommand).toHaveBeenCalledTimes(1) + expect(execCommand).toHaveBeenCalledTimes(2) expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca.cmd', - expect.stringContaining('@echo off'), + 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', + expect.stringContaining('ProcessStartInfo'), { hostPlatform: getRemoteHostPlatform('win32-x64') } ) - const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1') - expect(shim).toContain('\\\\.\\pipe\\orca-relay-123') - expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"') - expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket') - expect(shim).not.toContain('#!/usr/bin/env sh') + const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string + expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') + expect(launcherSource).not.toContain('cmd.exe') + expect(launcherSource).not.toContain('%*') + expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') + expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( false ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 05b0068cade..b449daabb70 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -61,7 +61,8 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -653,14 +654,14 @@ export class SshRelaySession { } try { - await this.installRemoteOrcaCliShim() + await this.installRemoteOrcaCliLauncher() } catch (error) { - // Why: the remote `orca` CLI shim is a convenience bridge. On session- + // Why: the remote `orca` CLI launcher is a convenience bridge. On session- // limited remotes (MaxSessions=1) the relay bridge holds the only slot, // so this raw-connection install can fail — that must not fail the // whole connection, matching the managed-hook install above. console.warn( - `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${ + `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) @@ -775,34 +776,38 @@ export class SshRelaySession { } } - private async installRemoteOrcaCliShim(): Promise { + private async installRemoteOrcaCliLauncher(): Promise { if (!this.remoteCliBridgeEnv) { return } const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const shim = buildRemoteCliShim(this.remoteCliBridgeEnv) + const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) const conn = this.requireReadyConnection() await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) if (typeof conn.writeFile === 'function') { - await conn.writeFile(shim.path, shim.contents, { hostPlatform }) + for (const file of plan.files) { + await conn.writeFile(file.path, file.contents, { hostPlatform }) + } } else { const sftp = await conn.sftp() try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(shim.path) - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(shim.contents) - }) + for (const file of plan.files) { + await new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(file.path) + sftp.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(file.contents) + }) + } } finally { sftp.end() } } - if (!isWindowsRemoteHost(hostPlatform)) { - await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path)) + for (const command of plan.postWriteCommands) { + await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) } } @@ -1264,47 +1269,3 @@ export class SshRelaySession { } } } - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function buildRemoteCliShim(env: RemoteCliBridgeEnv): { - path: string - contents: string -} { - if (isWindowsRemoteHost(env.hostPlatform)) { - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - return { - path: shimPath, - contents: [ - '@echo off', - 'setlocal', - `if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`, - `if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`, - `if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`, - '"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*', - 'exit /b %ERRORLEVEL%', - '' - ].join('\r\n') - } - } - - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - path: shimPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', - '' - ].join('\n') - } -} diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts new file mode 100644 index 00000000000..ae0a2b18619 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -0,0 +1,143 @@ +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const itWindows = process.platform === 'win32' ? it : it.skip + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded) { + throw new Error(`Expected an encoded PowerShell command: ${command}`) + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH remote Orca CLI launcher', () => { + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const hostPlatform = getRemoteHostPlatform('win32-x64') + const plan = createRemoteCliInstallPlan({ + binDir: 'C:/Users/me user/.orca-relay/bin', + relayDir: 'C:/Users/me user/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + hostPlatform + }) + + expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') + expect(plan.files).toHaveLength(1) + expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(plan.files[0]?.contents).toContain('ProcessStartInfo') + expect(plan.files[0]?.contents).toContain('"--orca-cli"') + expect(plan.files[0]?.contents).toContain("value[index] == '\"'") + expect(plan.files[0]?.contents).toContain("character == '\\\\'") + expect(plan.files[0]?.contents).not.toContain('cmd.exe') + expect(plan.files[0]?.contents).not.toContain('%*') + + expect(plan.postWriteCommands).toHaveLength(1) + const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') + expect(compileScript).toContain('v4.0.30319\\csc.exe') + // Why: legacy csc.exe is invoked from the bin directory with bare, space-free + // file names so PowerShell 5.1 never mangles a space-bearing absolute path. + expect(compileScript).toContain( + "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" + ) + expect(compileScript).toContain('/out:orca.exe') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') + expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + itWindows('preserves a multiline argument through the compiled remote launcher', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + const relayDir = join(root, 'relay').replaceAll('\\', '/') + const sockPath = '\\\\.\\pipe\\orca-relay-test' + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir, + nodePath: process.execPath, + sockPath, + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8' } + ) + expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) + + mkdirSync(relayDir, { recursive: true }) + writeFileSync( + join(relayDir, 'relay.js'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', + 'utf8' + ) + const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' + const launched = spawnSync( + plan.launcherPath, + ['orchestration', 'send', '--body', body, '--json'], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_RELAY_NODE_PATH: process.execPath, + ORCA_RELAY_DIR: relayDir, + ORCA_RELAY_SOCKET_PATH: sockPath + } + } + ) + + expect(launched.status, launched.stderr).toBe(0) + expect(JSON.parse(launched.stdout)).toEqual([ + '--sock-path', + sockPath, + '--orca-cli', + 'orchestration', + 'send', + '--body', + body, + '--json' + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { + const plan = createRemoteCliInstallPlan({ + binDir: '/home/me/.orca-relay/bin', + relayDir: '/home/me/.orca-remote/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') + expect(plan.files).toEqual([ + expect.objectContaining({ + path: '/home/me/.orca-relay/bin/orca', + contents: expect.stringContaining('--orca-cli "$@"') + }) + ]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts new file mode 100644 index 00000000000..871cc6076ac --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -0,0 +1,237 @@ +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' + +type RemoteCliInstallEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + hostPlatform: RemoteHostPlatform +} + +type RemoteCliInstallFile = { + path: string + contents: string +} + +export type RemoteCliInstallPlan = { + launcherPath: string + files: RemoteCliInstallFile[] + postWriteCommands: string[] +} + +const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaRemoteCliLauncher +{ + private static int Main(string[] args) + { + try + { + string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); + string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); + string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); + string relayPath = Path.Combine(relayDirectory, "relay.js"); + + if (!File.Exists(nodePath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); + return 1; + } + if (!File.Exists(relayPath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = nodePath, + Arguments = BuildArguments(relayPath, socketPath, args), + UseShellExecute = false + }; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); + return 1; + } + } + + private static string RequireEnvironmentVariable(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (String.IsNullOrEmpty(value)) + { + throw new InvalidOperationException(name + " is not set."); + } + return value; + } + + private static string BuildArguments(string relayPath, string socketPath, string[] args) + { + StringBuilder commandLine = new StringBuilder(); + AppendArgument(commandLine, relayPath); + AppendArgument(commandLine, "--sock-path"); + AppendArgument(commandLine, socketPath); + AppendArgument(commandLine, "--orca-cli"); + foreach (string arg in args) + { + AppendArgument(commandLine, arg); + } + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string value) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(value)); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +` + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function createWindowsLauncherCompileCommand( + binDir: string, + sourceFileName: string, + launcherFileName: string, + launcherPath: string, + sourcePath: string, + legacyShimPath: string +): string { + // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by + // Windows PowerShell 5.1's native-argument quoting, so compile from the bin + // directory and pass only the bare, space-free launcher file names. + const compilerArgs = [ + '/nologo', + '/target:exe', + '/optimize+', + '/warnaserror+', + `/out:${launcherFileName}`, + sourceFileName + ] + .map(powerShellNativeArg) + .join(' ') + return powerShellCommand( + [ + // Why: an upgrade must not leave the old %* batch bridge callable when + // compiler discovery fails; losing the convenience CLI is safer. + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, + `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, + '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', + `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, + '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + `& $compiler ${compilerArgs}`, + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` + ].join('; ') + ) +} + +export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { + if (isWindowsRemoteHost(env.hostPlatform)) { + const launcherFileName = 'orca.exe' + const sourceFileName = 'orca-launcher.cs' + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) + const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) + const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') + const binDir = joinRemotePath(env.hostPlatform, env.binDir) + return { + launcherPath, + files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], + // Why: compiling on the Windows target avoids shipping an unsigned + // cross-host binary while ensuring argv never crosses cmd.exe's parser. + postWriteCommands: [ + createWindowsLauncherCompileCommand( + binDir, + sourceFileName, + launcherFileName, + launcherPath, + sourcePath, + legacyShimPath + ) + ] + } + } + + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') + return { + launcherPath, + files: [ + { + path: launcherPath, + contents: [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', + '' + ].join('\n') + } + ], + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`] + } +} From 01d7cd779f3e949e0e4189f76e9648df029ae517 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:02:52 -0700 Subject: [PATCH 35/54] P2 mobile firewall scope (#8639) * fix(mobile): validate Windows firewall remote scope * fix(review): simplify string-guard ternary to boolean AND The ternary returned only boolean literals, so cond ? f() : false is equivalent to cond && f() (addressScopeIsSufficient returns boolean). Co-authored-by: Orca * fix(mobile): accept dotted-netmask firewall scopes and lock in fail-safe edges - Parse dotted-netmask CIDR (192.168.0.0/255.255.255.0) via a contiguous-mask check, failing closed on holey masks. - Factor subnetFromParsed so CIDR parsing no longer re-parses the address. - Document why single-host (/32, /128) subnets and family-specific keywords with an unknown interface family fail closed, and add regression tests covering those deliberate false-deny edges plus policy-defined keywords (Intranet, DNS). Co-authored-by: Orca * Add explanatory comment on why firewall scope check isn't unioned Documents the fail-safe rationale behind checking coverage per rule instead of merging rule scopes, so future edits don't "fix" this into a less conservative union check. --------- Co-authored-by: Orca --- .../windows-firewall-remote-scope.test.ts | 155 +++++++++++++ .../runtime/windows-firewall-remote-scope.ts | 216 ++++++++++++++++++ .../runtime/windows-mobile-firewall.test.ts | 43 +++- src/main/runtime/windows-mobile-firewall.ts | 28 ++- 4 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/windows-firewall-remote-scope.test.ts create mode 100644 src/main/runtime/windows-firewall-remote-scope.ts diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts new file mode 100644 index 00000000000..2c9a013d964 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' + +type RuleScope = { remoteAddresses: unknown } + +function rule(remoteAddresses: unknown): RuleScope { + return { remoteAddresses } +} + +describe('Windows firewall remote-address scope', () => { + it.each([['Any'], ['any'], ['LocalSubnet']])('accepts the documented %s scope', (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(scope)], undefined, undefined)).toBe(true) + }) + + it.each([['Any4'], ['LocalSubnet4']])( + 'accepts address-family-specific %s on the selected IPv4 interface', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + true + ) + } + ) + + it('keeps address-family-specific keywords on the selected interface family', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], 'fd7a:115c:a1e0::5', 64)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], 'fd7a:115c:a1e0::5', 64) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], '192.168.0.108', 24) + ).toBe(false) + }) + + it.each([ + ['192.168.0.0/24', '192.168.0.108', 24], + ['192.168.0.0-192.168.0.255', '192.168.0.108', 24], + ['fd7a:115c:a1e0::/64', 'fd7a:115c:a1e0::5', 64], + ['fd7a:115c:a1e0::-fd7a:115c:a1e0:0:ffff:ffff:ffff:ffff', 'fd7a:115c:a1e0::5', 64] + ])('accepts explicit scope %s covering the selected local subnet', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(true) + }) + + it.each([ + ['192.168.1.0/24', '192.168.0.108', 24], + ['192.168.0.64/26', '192.168.0.108', 24], + ['192.168.0.108', '192.168.0.108', 24], + ['fd7a:115c:a1e1::/64', 'fd7a:115c:a1e0::5', 64], + ['Internet', '192.168.0.108', 24] + ])('rejects restrictive or unsupported scope %s', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(false) + }) + + it('does not infer explicit scope coverage without selected interface subnet data', () => { + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], undefined, undefined) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], '192.168.0.108', 40) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it.each([ + undefined, + null, + [], + {}, + [rule(undefined)], + [rule([])], + [rule([''])], + [rule(['not-an-address'])], + [{ remoteAddresses: [42] }] + ])('rejects malformed or empty structured output %#', (rules) => { + expect(hasSufficientWindowsFirewallRemoteScope(rules, '192.168.0.108', 24)).toBe(false) + }) + + it('evaluates each rule independently instead of merging partial ranges', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0-192.168.0.127']), rule(['192.168.0.128-192.168.0.255'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.1.0/24']), rule(['192.168.0.0/24'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts PowerShell single-object and single-string JSON shapes', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + { remoteAddresses: '192.168.0.0/24' }, + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts dotted-netmask CIDR with a contiguous mask and rejects a holey one', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.255.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.0.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + }) + + it('treats a single-host (/32) interface as coverable only by Any/LocalSubnet keywords', () => { + // A /32 subnet is just the desktop itself, so an explicit range cannot prove + // the phone (a different host) is allowed — only the keywords can. + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it('fails address-family keywords closed when the interface family is unknown', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( + false + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], undefined, undefined)).toBe( + false + ) + }) + + it.each([['Intranet'], ['DNS'], ['DHCP'], ['DefaultGateway'], ['PlayToDevice']])( + 'fails the policy-defined %s keyword closed rather than assuming subnet coverage', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + false + ) + } + ) +}) diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts new file mode 100644 index 00000000000..9845413d079 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -0,0 +1,216 @@ +type IpVersion = 4 | 6 + +type ParsedIpAddress = { + version: IpVersion + bits: 32 | 128 + value: bigint +} + +type IpRange = { + version: IpVersion + start: bigint + end: bigint +} + +export function hasSufficientWindowsFirewallRemoteScope( + ruleScopes: unknown, + localAddress: unknown, + localPrefixLength: unknown +): boolean { + const rules = Array.isArray(ruleScopes) ? ruleScopes : [ruleScopes] + const localSubnet = parseSubnet(localAddress, localPrefixLength) + + // Why: coverage is checked per scope, never unioned across rules — this + // advisory check fails safe, so accepting fragmented rules only adds risk. + return rules.some((rule) => ruleHasSufficientScope(rule, localSubnet)) +} + +function ruleHasSufficientScope(rule: unknown, localSubnet: IpRange | null): boolean { + if (!isRecord(rule)) { + return false + } + const addresses = Array.isArray(rule.remoteAddresses) + ? rule.remoteAddresses + : [rule.remoteAddresses] + + return addresses.some( + (address) => typeof address === 'string' && addressScopeIsSufficient(address, localSubnet) + ) +} + +function addressScopeIsSufficient(scope: string, localSubnet: IpRange | null): boolean { + const normalized = scope.trim().toLowerCase() + if (normalized === 'any' || normalized === 'localsubnet') { + return true + } + if ( + normalized === 'any4' || + normalized === 'any6' || + normalized === 'localsubnet4' || + normalized === 'localsubnet6' + ) { + // Why: these keywords cover a single family, so they need the selected + // interface's family; an unknown family (null subnet) fails closed. + return localSubnet?.version === (normalized.endsWith('4') ? 4 : 6) + } + if (!localSubnet) { + return false + } + + // Why: without a phone IP we can only prove coverage when the rule spans the + // whole selected subnet; a single-host subnet (/32, /128 VPN/Tailscale) is just + // the desktop, so start !== end blocks a desktop-only rule from a false-allow. + const explicitRange = parseIpRange(scope) + return ( + localSubnet.start !== localSubnet.end && + explicitRange?.version === localSubnet.version && + explicitRange.start <= localSubnet.start && + explicitRange.end >= localSubnet.end + ) +} + +function parseSubnet(address: unknown, prefixLength: unknown): IpRange | null { + if (typeof address !== 'string' || typeof prefixLength !== 'number') { + return null + } + const parsed = parseIpAddress(address) + return parsed ? subnetFromParsed(parsed, prefixLength) : null +} + +function subnetFromParsed(parsed: ParsedIpAddress, prefixLength: number): IpRange | null { + if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > parsed.bits) { + return null + } + const hostBits = BigInt(parsed.bits - prefixLength) + const hostMask = hostBits === 0n ? 0n : (1n << hostBits) - 1n + const start = parsed.value & ~hostMask + return { version: parsed.version, start, end: start | hostMask } +} + +// Why: Windows also accepts dotted-netmask CIDR (192.168.0.0/255.255.255.0); +// convert a contiguous mask to a prefix length and fail closed on holey masks. +function maskPrefixLength(maskText: string, version: IpVersion): number | null { + const mask = parseIpAddress(maskText) + if (!mask || mask.version !== version) { + return null + } + const fullMask = (1n << BigInt(mask.bits)) - 1n + const hostPart = ~mask.value & fullMask + if ((hostPart & (hostPart + 1n)) !== 0n) { + return null + } + let hostBits = 0 + for (let remaining = hostPart; remaining > 0n; remaining >>= 1n) { + hostBits += 1 + } + return mask.bits - hostBits +} + +function parseIpRange(scope: string): IpRange | null { + const trimmed = scope.trim() + const dashIndex = trimmed.indexOf('-') + if (dashIndex >= 0) { + if (dashIndex !== trimmed.lastIndexOf('-')) { + return null + } + const start = parseIpAddress(trimmed.slice(0, dashIndex)) + const end = parseIpAddress(trimmed.slice(dashIndex + 1)) + if (!start || !end || start.version !== end.version || start.value > end.value) { + return null + } + return { version: start.version, start: start.value, end: end.value } + } + + const slashIndex = trimmed.indexOf('/') + if (slashIndex >= 0) { + if (slashIndex !== trimmed.lastIndexOf('/')) { + return null + } + const address = parseIpAddress(trimmed.slice(0, slashIndex)) + if (!address) { + return null + } + const suffix = trimmed.slice(slashIndex + 1) + const prefixLength = /^\d+$/.test(suffix) + ? Number(suffix) + : maskPrefixLength(suffix, address.version) + return prefixLength === null ? null : subnetFromParsed(address, prefixLength) + } + + const address = parseIpAddress(trimmed) + return address ? { version: address.version, start: address.value, end: address.value } : null +} + +function parseIpAddress(input: string): ParsedIpAddress | null { + const trimmed = input.trim() + const bracketed = trimmed.startsWith('[') || trimmed.endsWith(']') + if (bracketed && !(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return null + } + const address = (bracketed ? trimmed.slice(1, -1) : trimmed).split('%', 1)[0] ?? '' + return address.includes(':') ? parseIpv6(address) : parseIpv4(address) +} + +function parseIpv4(address: string): ParsedIpAddress | null { + const octets = address.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return null + } + const values = octets.map(Number) + if (values.some((octet) => octet > 255)) { + return null + } + const value = values.reduce((result, octet) => (result << 8n) | BigInt(octet), 0n) + return { version: 4, bits: 32, value } +} + +function parseIpv6(address: string): ParsedIpAddress | null { + const expandedAddress = expandEmbeddedIpv4(address) + if (!expandedAddress) { + return null + } + const halves = expandedAddress.split('::') + if (halves.length > 2) { + return null + } + const left = splitIpv6Half(halves[0] ?? '') + const right = splitIpv6Half(halves[1] ?? '') + if (!left || !right) { + return null + } + + const hasCompression = halves.length === 2 + const missingGroups = 8 - left.length - right.length + if ((!hasCompression && missingGroups !== 0) || (hasCompression && missingGroups < 1)) { + return null + } + const groups = [...left, ...Array(missingGroups).fill('0'), ...right] + const value = groups.reduce((result, group) => (result << 16n) | BigInt(`0x${group}`), 0n) + return { version: 6, bits: 128, value } +} + +function expandEmbeddedIpv4(address: string): string | null { + if (!address.includes('.')) { + return address + } + const lastColon = address.lastIndexOf(':') + const ipv4 = parseIpv4(address.slice(lastColon + 1)) + if (lastColon < 0 || !ipv4) { + return null + } + const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) + const low = (ipv4.value & 0xffffn).toString(16) + return `${address.slice(0, lastColon)}:${high}:${low}` +} + +function splitIpv6Half(half: string): string[] | null { + if (half === '') { + return [] + } + const groups = half.split(':') + return groups.every((group) => /^[\da-f]{1,4}$/i.test(group)) ? groups : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 4a7cc8a1219..56464c15bf1 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -24,7 +24,9 @@ describe('windows mobile firewall', () => { it('inspects the exact executable, port, and selected interface profile', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ - ruleAllowed: true, + matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, privateFirewallEnabled: true, networkCategory: 'Private' }) @@ -47,6 +49,29 @@ describe('windows mobile firewall', () => { expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") expect(script).toContain("$profile -match 'Private'") expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + expect(script).toContain('Get-NetFirewallAddressFilter') + expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress') + expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') + }) + + it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['192.168.1.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: true + }) }) it('does not support non-Windows or unpackaged development builds', async () => { @@ -81,6 +106,22 @@ describe('windows mobile firewall', () => { }) }) + it('returns an actionable status for malformed or empty PowerShell output', async () => { + for (const stdout of ['', 'not json']) { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockResolvedValue(stdout)) + ) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: false + }) + } + }) + it('repairs only Orca mobile pairing on private networks after elevation', async () => { const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index b6419da9f5d..36797b83eb2 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -5,6 +5,7 @@ import type { WindowsMobileFirewallStatus, WindowsNetworkCategory } from '../../shared/windows-mobile-firewall' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' const FIREWALL_RULE_NAME = 'Orca.MobilePairing' const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' @@ -22,7 +23,9 @@ export type WindowsMobileFirewallEnvironment = { } type FirewallInspection = { - ruleAllowed: boolean + matchingRuleScopes?: unknown + localAddress?: unknown + localPrefixLength?: unknown privateFirewallEnabled: boolean networkCategory: string } @@ -51,7 +54,11 @@ export async function inspectWindowsMobileFirewall( return { supported: true, port, - ruleAllowed: result.ruleAllowed === true, + ruleAllowed: hasSufficientWindowsFirewallRemoteScope( + result.matchingRuleScopes, + result.localAddress, + result.localPrefixLength + ), privateFirewallEnabled: result.privateFirewallEnabled !== false, networkCategory: parseNetworkCategory(result.networkCategory), inspectionAvailable: true @@ -152,12 +159,16 @@ function buildInspectionScript(port: number, executablePath: string, address?: s ? ` try { $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $localAddress = [string]$ip.IPAddress + $localPrefixLength = [int]$ip.PrefixLength $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 if ($profile) { $networkCategory = [string]$profile.NetworkCategory } } catch {}` : '' + // Why: NetSecurity filter properties are stable across localized Windows + // display output and keep every rule's address scope independent. return `$ErrorActionPreference = 'Stop' -$ruleAllowed = $false +$matchingRuleScopes = @() $rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) foreach ($rule in $rules) { $portFilter = $rule | Get-NetFirewallPortFilter @@ -165,16 +176,21 @@ foreach ($rule in $rules) { $profile = [string]$rule.Profile $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { - $ruleAllowed = $true + $addressFilter = $rule | Get-NetFirewallAddressFilter + $matchingRuleScopes += [pscustomobject]@{ + remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + } } } $privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled $networkCategory = 'Unknown'${addressLookup} [pscustomobject]@{ - ruleAllowed = $ruleAllowed + matchingRuleScopes = @($matchingRuleScopes) + localAddress = $localAddress + localPrefixLength = $localPrefixLength privateFirewallEnabled = $privateFirewallEnabled networkCategory = $networkCategory -} | ConvertTo-Json -Compress` +} | ConvertTo-Json -Depth 4 -Compress` } function buildRepairScript(port: number, executablePath: string): string { From 79369d5396355ed1e119a008e89089f28295f3de Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:08:46 -0700 Subject: [PATCH 36/54] Fail loudly on chmod errors in remote CLI launcher install (#8648) Silently swallowing chmod failures let installs proceed with a non-executable launcher, surfacing as a confusing runtime error later instead of a clear install-time failure. --- src/main/ssh/ssh-remote-cli-launcher.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts index 871cc6076ac..a246ce9c9b8 100644 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -232,6 +232,7 @@ export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliI ].join('\n') } ], - postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`] + // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] } } From f93e92646cb3c3791e8aec584974f6a41da8a246 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:12:30 -0700 Subject: [PATCH 37/54] P2 watcher lifecycle bounds (#8640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix watcher lifecycle cancellation bounds * fix(review): guard remote watcher installs against post-shutdown resurrection closeAllWatchers aborted the in-flight install *tokens* it could see, but a same-key joiner awaiting a 'cancelled' resolution (and a fired retry tick) calls installRemoteWatcher directly and, on the fresh-generation recursion, builds a brand-new non-aborted AbortController and calls provider.watch() after teardown — leaking an SSH watcher into the just-cleared remoteWatchers map. Latch the subsystem shut in closeAllWatchers and refuse installs while latched; a genuine new fs:watchWorktree clears it. Adds a regression test (fails without the latch) plus a test for the same-tick handoff-revival guard that had no coverage. Also extract the duplicated isolated-quarantine-vs-fuse branch shared by retireSlot and releaseFailedRoot into quarantineOrFuse (behavior-preserving). * Add lifecycle generation guard to refuse stale remote-watcher joiners - A boolean latch alone can't distinguish a pre-shutdown joiner from a fresh call once a genuine new watch reopens the subsystem, letting a stale joiner recurse and register a post-shutdown provider.watch() - Each installRemoteWatcher call now captures a generation counter that closeAllWatchers bumps, so a waiter that resumes after a later shutdown+reopen is refused instead of resurrecting - Adds a regression test covering the shutdown-then-reopen race --- ...system-watcher-remote-cancellation.test.ts | 299 ++++++++++++++++++ src/main/ipc/filesystem-watcher.test.ts | 8 +- src/main/ipc/filesystem-watcher.ts | 121 +++++-- .../ipc/runtime-watcher-process-pool.test.ts | 55 ++++ src/main/ipc/runtime-watcher-process-pool.ts | 44 ++- 5 files changed, 483 insertions(+), 44 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-remote-cancellation.test.ts diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts new file mode 100644 index 00000000000..3e22cb6b835 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('remote filesystem watcher cancellation', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + try { + expect(watchMock).toHaveBeenCalledTimes(1) + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(true) + } finally { + resolveInstall?.(lateUnwatch) + await Promise.all([first, second]) + } + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('starts a fresh same-root generation when a listener arrives after physical abort', async () => { + let firstSignal: AbortSignal | undefined + let secondCallback: ((events: unknown[]) => void) | undefined + const secondUnwatch = vi.fn() + const watchMock = vi + .fn() + .mockImplementationOnce( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((_resolve, reject) => { + firstSignal = options?.signal + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + .mockImplementationOnce((_rootPath, callback) => { + secondCallback = callback + return Promise.resolve(secondUnwatch) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + + await Promise.all([first, second]) + expect(watchMock).toHaveBeenCalledTimes(2) + secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(secondSender.send).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + expect(secondUnwatch).toHaveBeenCalledTimes(1) + }) + + it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const destroyedCallbacks: (() => void)[] = [] + const destroyedSender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' } + const destroyedWatch = handlers['fs:watchWorktree']( + { sender: destroyedSender }, + destroyedArgs + ) as Promise + + await Promise.resolve() + destroyedCallbacks[0]() + await Promise.resolve() + expect(installs.get('/destroyed')?.signal?.aborted).toBe(true) + installs.get('/destroyed')?.resolve(vi.fn()) + await destroyedWatch + + const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' } + const shutdownWatch = handlers['fs:watchWorktree']( + { sender: shutdownSender }, + shutdownArgs + ) as Promise + + await Promise.resolve() + await closeAllWatchers() + expect(installs.get('/shutdown')?.signal?.aborted).toBe(true) + installs.get('/shutdown')?.resolve(vi.fn()) + await shutdownWatch + }) + + it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // The last listener leaves and a replacement joins in the SAME tick — before + // the queued abort microtask runs. The shared install must survive. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + expect(watchMock).toHaveBeenCalledTimes(1) + + resolveInstall?.(vi.fn()) + await Promise.all([first, second]) + }) + + it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => { + let firstSignal: AbortSignal | undefined + let resolveFirst: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + firstSignal = options?.signal + resolveFirst = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it awaits the + // 'cancelled' resolution and would recurse into a fresh install. + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + await Promise.resolve() + + // Shutdown latches the subsystem before the joiner's recursion runs. + await closeAllWatchers() + + // Late success of the aborted generation must be unwatched, not registered. + resolveFirst?.(lateUnwatch) + await Promise.all([first, second]) + + // The recursion is refused post-shutdown: provider.watch() is never called again. + expect(watchMock).toHaveBeenCalledTimes(1) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' } + const lateUnwatch = vi.fn() + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it captures the + // current lifecycle generation and awaits the 'cancelled' resolution. + const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise + await Promise.resolve() + + // Shutdown bumps the generation, then a genuine new watch reopens the subsystem + // (clearing the boolean latch) before the joiner resumes. + await closeAllWatchers() + const reopen = handlers['fs:watchWorktree']( + { sender: reopenSender }, + reopenArgs + ) as Promise + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(2) + + // Now let the aborted install resolve; the joiner recurses on the stale generation. + installs.get('/home/me/repo')?.resolve(lateUnwatch) + installs.get('/home/me/other')?.resolve(vi.fn()) + await Promise.all([first, joiner, reopen]) + + // The joiner's recursion is refused despite the reopen: no third provider.watch(). + expect(watchMock).toHaveBeenCalledTimes(2) + expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength( + 1 + ) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 0b3b07ca071..f776e0d494d 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) await vi.advanceTimersByTimeAsync(1_000) - expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) const onEvents = watchMock.mock.calls[0][1] onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { @@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) - expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) handlers['fs:unwatchWorktree']( { sender: { id: 1 } }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 983fc9ce985..665b543779f 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -652,22 +652,31 @@ type RemoteWatcherState = { type RemoteWatcherInstallToken = { cancelled: boolean listeners: Map + abortController: AbortController + abortScheduled: boolean } // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() -// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that -// arrives while a watch is still resolving can mark the install cancelled. -// Without this, the awaited unwatch handle would be installed after the -// renderer thinks the watch is gone, leaking a native watcher. +// Why: track in-flight `provider.watch()` calls so last-listener cleanup can +// abort relay setup, while late success is still unwatched instead of leaked. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls for the same key so // overlapping fs:watchWorktree IPCs share one native watcher and one listener // map, instead of each call independently invoking provider.watch() and // overwriting the per-key state on resolution. const pendingRemoteInstallPromises = new Map>() +// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner +// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing +// the token-abort loop. A genuine new fs:watchWorktree clears the latch. +let remoteWatchersClosed = false +// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a +// fresh call once a genuine new watch reopens the subsystem. Each call captures +// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited +// across a shutdown+reopen recurses on a stale generation and is refused. +let remoteWatcherLifecycleGeneration = 0 const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -675,7 +684,7 @@ function addInFlightRemoteInstallListener( token: RemoteWatcherInstallToken, sender: WebContents ): void { - if (sender.isDestroyed()) { + if (sender.isDestroyed() || token.abortController.signal.aborted) { return } token.listeners.set(sender.id, sender) @@ -683,12 +692,26 @@ function addInFlightRemoteInstallListener( registerSenderCleanup(sender) } +function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void { + token.cancelled = token.listeners.size === 0 + if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) { + return + } + token.abortScheduled = true + // Why: a replacement sender can synchronously revive the shared install + // during a renderer handoff; otherwise stop the relay crawl next microtask. + queueMicrotask(() => { + token.abortScheduled = false + if (token.cancelled && token.listeners.size === 0) { + token.abortController.abort() + } + }) +} + function cleanupInFlightRemoteInstallsForSender(senderId: number): void { for (const token of inFlightRemoteInstalls.values()) { token.listeners.delete(senderId) - if (token.listeners.size === 0) { - token.cancelled = true - } + cancelInFlightRemoteInstallIfUnowned(token) } } @@ -726,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' async function installRemoteWatcher( sender: WebContents, connectionId: string, - worktreePath: string + worktreePath: string, + generation = remoteWatcherLifecycleGeneration ): Promise { + // Why: refuse installs racing in after teardown (joiner recursion, fired retry + // tick) so provider.watch() is never called and registered post-shutdown. The + // generation guard also refuses a waiter that captured an earlier lifecycle, + // even after a new watch reopened the subsystem. + if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) { + return 'cancelled' + } const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { return 'unavailable' @@ -747,7 +778,8 @@ async function installRemoteWatcher( const pendingInstall = pendingRemoteInstallPromises.get(key) if (pendingInstall) { const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { + const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted + if (canJoinInstall) { // Why: a new watcher can join after all previous pending listeners // unwatched but before provider.watch() resolves; revive that install // instead of inheriting the stale cancellation. @@ -762,9 +794,27 @@ async function installRemoteWatcher( ) { addRemoteWatchListener(key, sender) } + if ( + result === 'cancelled' && + !canJoinInstall && + !sender.isDestroyed() && + generation === remoteWatcherLifecycleGeneration + ) { + // Why: AbortSignal cannot be revived. A listener arriving after physical + // cancellation waits out that generation, then owns a fresh install. + if (pendingRemoteInstallPromises.get(key) === pendingInstall) { + pendingRemoteInstallPromises.delete(key) + } + return installRemoteWatcher(sender, connectionId, worktreePath, generation) + } return result } - const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() } + const cancelToken: RemoteWatcherInstallToken = { + cancelled: false, + listeners: new Map(), + abortController: new AbortController(), + abortScheduled: false + } inFlightRemoteInstalls.set(key, cancelToken) addInFlightRemoteInstallListener(cancelToken, sender) const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) @@ -786,22 +836,29 @@ async function doInstallRemoteWatcher( ): Promise { let unwatch: () => void try { - unwatch = await provider.watch(worktreePath, (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return - } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue + unwatch = await provider.watch( + worktreePath, + (events) => { + const state = remoteWatchers.get(key) + if (!state) { + return } - listener.send('fs:changed', { - worktreePath, - events - } satisfies FsChangedPayload) - } - }) + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue + } + listener.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }, + { signal: cancelToken.abortController.signal } + ) } catch (err) { + if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { + return 'cancelled' + } console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err) return 'unavailable' } finally { @@ -884,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { + // Why: a real new watch reopens the subsystem after closeAllWatchers + // latched it shut (also how tests reset between cases). + remoteWatchersClosed = false const key = `${args.connectionId}:${args.worktreePath}` const result = await installRemoteWatcher( event.sender, @@ -923,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void { const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.delete(_event.sender.id) - inFlight.cancelled = inFlight.listeners.size === 0 + cancelInFlightRemoteInstallIfUnowned(inFlight) } loggedUnavailableRemoteWatchers.delete(key) releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) @@ -951,10 +1011,19 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() loggedUnavailableRemoteWatchers.clear() + // Why: latch the subsystem shut and drop the dedup map so a late install that + // begins after teardown is refused instead of registering post-shutdown. Bump + // the generation so a waiter that resumes after a later reopen still recurses + // on a stale lifecycle and is refused. + remoteWatchersClosed = true + remoteWatcherLifecycleGeneration += 1 + pendingRemoteInstallPromises.clear() // Why: cancel any in-flight provider.watch() calls so their resolved // unwatch handles are discarded instead of being installed after shutdown. for (const token of inFlightRemoteInstalls.values()) { + token.listeners.clear() token.cancelled = true + token.abortController.abort() } for (const token of inFlightLocalInstalls.values()) { token.listeners.clear() diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts index e43112e4ef4..5cd624589b2 100644 --- a/src/main/ipc/runtime-watcher-process-pool.test.ts +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -162,6 +162,30 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow']) }) + it('allows only one quarantine generation when isolated setup also times out', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher subscription timed out', + 'subscription', + 'subscribe_timeout' + ) + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisor.subscribeError = timeout + supervisors.push(supervisor) + return supervisor + } + }) + + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('moves a live root into quarantine when crash resubscription times out', async () => { const timeout = new WatcherProcessFailure( 'file watcher resubscription timed out', @@ -179,6 +203,37 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery']) }) + it('does not replace an isolated live root after its resubscription times out', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const fused = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + const timeout = new WatcherProcessFailure( + 'file watcher resubscription timed out', + 'subscription', + 'subscribe_timeout' + ) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + + supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout) + + await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('keeps healthy shard assignments after a root-specific failure', async () => { pool = new RuntimeWatcherProcessPool({ maxSharedSupervisors: 1, diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index b5a2bf45d44..b4e46cb3d2c 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -77,10 +77,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } hooks.onTerminalError?.(error) } @@ -94,10 +91,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } throw error } @@ -207,14 +201,7 @@ export class RuntimeWatcherProcessPool { if (this.assignments.get(root)?.slot === slot) { this.assignments.delete(root) } - if (slot.isolated) { - // Why: one bounded quarantine attempt is the recovery budget for a - // watch lifetime; repeated fused replacements would recreate churn. - this.isolatedRoots.delete(root) - this.failedQuarantineRoots.add(root) - } else { - this.isolatedRoots.add(root) - } + this.quarantineOrFuse(root, slot.isolated) } slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal @@ -240,6 +227,31 @@ export class RuntimeWatcherProcessPool { } } + private releaseFailedRoot( + assignment: RuntimeWatcherPoolAssignment, + dir: string, + error: unknown, + releaseAssignment: () => void + ): void { + releaseAssignment() + if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') { + return + } + this.quarantineOrFuse(dir, assignment.slot.isolated) + } + + // Why: one bounded quarantine attempt is the recovery budget per watch + // lifetime; an already-isolated root that fails again is fused, not re-isolated, + // so it cannot spawn another child generation. + private quarantineOrFuse(dir: string, isolated: boolean): void { + if (isolated) { + this.isolatedRoots.delete(dir) + this.failedQuarantineRoots.add(dir) + return + } + this.isolatedRoots.add(dir) + } + private disposeSlot(slot: RuntimeWatcherPoolSlot): void { if (slot.disposed) { return From 2801c6b03b2ebb176f937a4e78297daf939a3ed8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:13:19 -0700 Subject: [PATCH 38/54] P2 live log undo memory (#8644) * fix(editor): avoid undo history for read-only live tails * Add reliability-gate evidence for read-only live-tail undo-history fix - Records the passing vitest run that verifies live-tail appends leave canUndo false while ordinary external updates stay undoable, backing the recent editor undo-history fix. --- config/reliability-gates.jsonc | 53 +++++++++++-- .../EditorContent.monaco-lifecycle.test.tsx | 31 +++++++- .../src/components/editor/EditorContent.tsx | 1 + .../src/components/editor/MonacoEditor.tsx | 18 ++++- .../editor/monaco-content-sync.test.ts | 60 +++++++++++++- .../components/editor/monaco-content-sync.ts | 79 +++++++++++-------- .../monaco-content-sync.undo-history.test.ts | 51 ++++++++++++ ...onaco-content-sync.undo-retention.bench.ts | 78 ++++++++++++++++++ .../src/components/editor/monaco-e2e-probe.ts | 2 + .../agent-session-log-tail-stability.spec.ts | 14 ++-- 10 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts create mode 100644 src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 6a9ba8db139..64ac34ed173 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-12", + "updatedAt": "2026-07-13", "policy": { "maturityLevels": [ "experimental", @@ -172,20 +172,24 @@ "providers": ["local"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", + "coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"], - "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.", - "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.", + "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.", + "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1" ], "testFiles": [ "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", "tests/e2e/agent-session-log-tail-stability.spec.ts" ], "assertionRefs": [ @@ -194,9 +198,14 @@ "assertions": [ "append-only drift reads the current model once and inserts only at the previous model end", "identical content emits no edit and non-append drift retains full replacement plus undo stops", + "read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits", "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated" ] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "assertions": ["a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"] + }, { "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"] @@ -209,12 +218,17 @@ "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts", "assertions": ["with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", + "assertions": ["five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB"] + }, { "file": "tests/e2e/agent-session-log-tail-stability.spec.ts", "assertions": [ "production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check", "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm", "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content", + "the production read-only live-tail model remains non-undoable before and after every watcher append", "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls" ] } @@ -246,12 +260,39 @@ "result": "passed", "durationSeconds": 78, "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 5, + "summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false." } ], "runtimeBudget": { "p95Seconds": 600, "scope": "local focused renderer tests plus one Electron production-journey scenario" }, "flakeHistory": { "status": "unknown", "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." }, - "redGreenEvidence": { "status": "complete", "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5." }, - "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls." }, + "redGreenEvidence": { "status": "complete", "evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control." }, + "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass." }, "promotionCriteria": [ "Collect stable soak history on macOS, Linux, and Windows.", "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes." diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 77c78d0a472..3251b43ac1a 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor' const lifecycle = vi.hoisted(() => ({ events: [] as string[], - models: new Map() + models: new Map(), + mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[] })) vi.mock('@/lib/lazy-with-retry', async () => { @@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => { if (!factory.toString().includes('/MonacoEditor.tsx')) { return () => null } - return function MockRetainedMonaco(props: { filePath: string; content: string }) { + return function MockRetainedMonaco(props: { + filePath: string + content: string + readOnly?: boolean + liveTail?: boolean + }) { /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ React.useEffect(() => { lifecycle.events.push(`mount:${props.filePath}`) + lifecycle.mountedProps.push({ + filePath: props.filePath, + readOnly: props.readOnly, + liveTail: props.liveTail + }) const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } const model = { getValue: () => retained.content, @@ -93,7 +104,7 @@ vi.mock('@/store', () => ({ import { EditorContent } from './EditorContent' -function file(filePath: string): OpenFile { +function file(filePath: string, overrides: Partial = {}): OpenFile { return { id: filePath, filePath, @@ -101,7 +112,8 @@ function file(filePath: string): OpenFile { worktreeId: 'repo::/repo', language: 'typescript', isDirty: false, - mode: 'edit' + mode: 'edit', + ...overrides } } @@ -136,6 +148,7 @@ afterEach(() => { cleanup() lifecycle.events.length = 0 lifecycle.models.clear() + lifecycle.mountedProps.length = 0 }) describe('EditorContent Monaco lifecycle boundary', () => { @@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => { undo: ['first undo'] }) }) + + it('passes live-tail ownership only for a read-only live log', () => { + const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true }) + + render() + + expect(lifecycle.mountedProps).toEqual([ + { filePath: liveLog.filePath, readOnly: true, liveTail: true } + ]) + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 3e56b7c58da..023c0b9fcae 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -327,6 +327,7 @@ export function EditorContent({ // the change/save callbacks so no draft, dirty state, or write can occur — // mirrors the conflict-review read-only rendering pattern. readOnly={activeFile.readOnly === true} + liveTail={activeFile.liveTail === true} onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange} onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave} worktreeId={activeFile.worktreeId} diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index d8f92a00cf3..daf288d8ce9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select import { useContextualCopySetup } from './useContextualCopySetup' import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal' -import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' +import { + syncContentOnMount, + syncContentUpdate, + type MonacoContentSyncMode +} from './monaco-content-sync' import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search' import { beginProgrammaticContentSync, @@ -77,6 +81,7 @@ type MonacoEditorProps = { markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean readOnly?: boolean + liveTail?: boolean autoHeight?: boolean } @@ -101,6 +106,7 @@ export default function MonacoEditor({ markdownAnnotationsEnabled = false, conflictDecorationsEnabled = false, readOnly = false, + liveTail = false, autoHeight = false }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) @@ -131,6 +137,8 @@ export default function MonacoEditor({ propsRef.current = { relativePath, language, onSave, onContentChange } const readOnlyRef = useRef(readOnly) readOnlyRef.current = readOnly + const contentSyncModeRef = useRef('undoable') + contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable' const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) @@ -370,7 +378,11 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current) + const didSyncOnMount = syncContentOnMount( + editorInstance, + contentRef.current, + contentSyncModeRef.current + ) if (didSyncOnMount) { lastSyncedContentRef.current = contentRef.current } @@ -672,7 +684,7 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - syncContentUpdate(ed, content) + syncContentUpdate(ed, content, contentSyncModeRef.current) lastSyncedContentRef.current = content } finally { isApplyingProgrammaticContentRef.current = false diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts index ccf958f122e..30f833d379c 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.test.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -9,6 +9,7 @@ function createHarness( editorInstance: editor.IStandaloneCodeEditor getValue: ReturnType getFullModelRange: ReturnType + applyEdits: ReturnType pushEditOperations: ReturnType pushUndoStop: ReturnType } { @@ -20,18 +21,27 @@ function createHarness( endColumn: 5 })) const pushEditOperations = vi.fn() + const applyEdits = vi.fn() const model = { getValue, getEOL: () => eol, getFullModelRange, - pushEditOperations + pushEditOperations, + applyEdits } as unknown as editor.ITextModel const pushUndoStop = vi.fn() const editorInstance = { getModel: () => model, pushUndoStop } as unknown as editor.IStandaloneCodeEditor - return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop } + return { + editorInstance, + getValue, + getFullModelRange, + applyEdits, + pushEditOperations, + pushUndoStop + } } describe('monaco content sync', () => { @@ -60,6 +70,26 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it('inserts a read-only live-tail suffix without recording undo history', () => { + const harness = createHarness('first\nsecond\nlast') + + syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { + range: { + startLineNumber: 3, + startColumn: 5, + endLineNumber: 3, + endColumn: 5 + }, + text: '\nnext' + } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing for identical content', () => { const harness = createHarness('unchanged') @@ -127,6 +157,22 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it.each([ + ['same-length rewrite', 'before', 'after!'], + ['prefix mismatch', 'before', 'changed content'], + ['truncation', 'longer content', 'short'] + ])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => { + const harness = createHarness(initialContent) + + syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('reconciles a stale retained model on mount without undo stops', () => { const harness = createHarness('stale') @@ -136,6 +182,16 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).not.toHaveBeenCalled() }) + it('reconciles a stale read-only live-tail model on mount without undo history', () => { + const harness = createHarness('stale') + + expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true) + + expect(harness.applyEdits).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing on mount when content already matches', () => { const harness = createHarness('same') diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index b7894af432c..fa19928b89d 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -1,5 +1,7 @@ import type { editor } from 'monaco-editor' +export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail' + function normalizeToModelEol(content: string, model: editor.ITextModel): string { const eol = model.getEOL() // Why: Monaco normalizes model line endings, while filesystem content keeps @@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string return content.replace(/\r\n|\r|\n/g, eol) } -/** - * Overwrite a Monaco model's text via pushEditOperations so the change - * participates in the undo stack (unlike setValue, which blows it away). - */ +function applyModelEdit( + editorInstance: editor.IStandaloneCodeEditor, + model: editor.ITextModel, + edit: editor.IIdentifiedSingleEditOperation, + mode: MonacoContentSyncMode, + withUndoStops: boolean +): void { + if (mode === 'read-only-live-tail') { + // Why: live-tail updates are machine-owned and cannot be undone by users; + // recording them would retain the growing log again in Monaco's undo service. + model.applyEdits([edit]) + return + } + if (withUndoStops) { + editorInstance.pushUndoStop() + } + model.pushEditOperations([], [edit], () => null) + if (withUndoStops) { + editorInstance.pushUndoStop() + } +} + function replaceModelContent( editorInstance: editor.IStandaloneCodeEditor, model: editor.ITextModel, currentContent: string, content: string, + mode: MonacoContentSyncMode, withUndoStops: boolean ): void { if (currentContent === content) { return } const fullRange = model.getFullModelRange() - if (withUndoStops) { - editorInstance.pushUndoStop() - } - model.pushEditOperations([], [{ range: fullRange, text: content }], () => null) - if (withUndoStops) { - editorInstance.pushUndoStop() - } + applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops) } /** @@ -46,7 +61,8 @@ function replaceModelContent( */ export function syncContentOnMount( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): boolean { const model = editorInstance.getModel() if (!model) { @@ -60,7 +76,7 @@ export function syncContentOnMount( // Why: no undo stop on mount — the retained model's text was already the // user's last-known state, and adding an undo entry here would make Cmd+Z // revert to the pre-remount text, which is confusing. - replaceModelContent(editorInstance, model, currentContent, normalizedContent, false) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false) return true } @@ -74,7 +90,8 @@ export function syncContentOnMount( */ export function syncContentUpdate( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): void { const model = editorInstance.getModel() if (!model) { @@ -83,7 +100,7 @@ export function syncContentUpdate( const currentContent = model.getValue() const normalizedContent = normalizeToModelEol(content, model) if (currentContent.length === normalizedContent.length) { - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) return } if ( @@ -93,24 +110,22 @@ export function syncContentUpdate( // Why: preserving the existing prefix lets Monaco retain viewport, // selection, find-widget, and tokenization state above a live-file append. const fullRange = model.getFullModelRange() - editorInstance.pushUndoStop() - model.pushEditOperations( - [], - [ - { - range: { - startLineNumber: fullRange.endLineNumber, - startColumn: fullRange.endColumn, - endLineNumber: fullRange.endLineNumber, - endColumn: fullRange.endColumn - }, - text: normalizedContent.slice(currentContent.length) - } - ], - () => null + applyModelEdit( + editorInstance, + model, + { + range: { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }, + text: normalizedContent.slice(currentContent.length) + }, + mode, + true ) - editorInstance.pushUndoStop() return } - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) } diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts new file mode 100644 index 00000000000..7118634a3b1 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import * as monaco from 'monaco-editor' +import { afterEach, describe, expect, it } from 'vitest' +import { syncContentUpdate } from './monaco-content-sync' + +const models: monaco.editor.ITextModel[] = [] + +function createEditor(initialContent: string): { + editorInstance: monaco.editor.IStandaloneCodeEditor + model: monaco.editor.ITextModel +} { + const model = monaco.editor.createModel(initialContent, 'plaintext') + models.push(model) + return { + model, + editorInstance: { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + } +} + +afterEach(() => { + for (const model of models.splice(0)) { + model.dispose() + } +}) + +describe('Monaco external-content undo history', () => { + it('does not make a read-only live-tail append undoable', () => { + const { editorInstance, model } = createEditor('first line') + + syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail') + + expect(model.getValue()).toBe('first line\nappended') + expect(model.canUndo()).toBe(false) + }) + + it('keeps ordinary external updates undoable', async () => { + const { editorInstance, model } = createEditor('editable') + + syncContentUpdate(editorInstance, 'external update') + + expect(model.canUndo()).toBe(true) + await model.undo() + expect(model.getValue()).toBe('editable') + }) +}) diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts new file mode 100644 index 00000000000..a5fdbe1de34 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { bench, expect } from 'vitest' +import * as monaco from 'monaco-editor' +import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync' + +const MEBIBYTE = 1024 * 1024 +const BATCH_COUNT = 5 +const BATCH_BYTES = 10 * MEBIBYTE + +type UndoStackElement = { heapSize: () => number } +type UndoRedoService = { + getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] } +} + +function undoHistoryBytes(model: monaco.editor.ITextModel): number { + const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService + const elements = service.getElements(model.uri) + return [...elements.past, ...elements.future].reduce( + (total, element) => total + element.heapSize(), + 0 + ) +} + +async function forceGcAndSettle(): Promise { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc') + } + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{ + arrayBufferDelta: number + canUndo: boolean + undoBytes: number +}> { + await forceGcAndSettle() + const beforeArrayBuffers = process.memoryUsage().arrayBuffers + const model = monaco.editor.createModel('', 'plaintext') + const editorInstance = { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + let content = '' + for (let batch = 0; batch < BATCH_COUNT; batch++) { + content += String(batch % 10).repeat(BATCH_BYTES) + syncContentUpdate(editorInstance, content, mode) + } + const canUndo = model.canUndo() + const undoBytes = undoHistoryBytes(model) + await forceGcAndSettle() + const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers + model.dispose() + await forceGcAndSettle() + return { arrayBufferDelta, canUndo, undoBytes } +} + +bench( + '50 MiB read-only live-tail undo retention', + async () => { + const undoable = await measureUndoRetention('undoable') + const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail') + console.log( + `[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}` + ) + expect(undoable.canUndo).toBe(true) + expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(readOnlyLiveTail.canUndo).toBe(false) + expect(readOnlyLiveTail.undoBytes).toBe(0) + expect(readOnlyLiveTail.arrayBufferDelta).toBe(0) + }, + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) diff --git a/src/renderer/src/components/editor/monaco-e2e-probe.ts b/src/renderer/src/components/editor/monaco-e2e-probe.ts index f6284a51771..788a2204794 100644 --- a/src/renderer/src/components/editor/monaco-e2e-probe.ts +++ b/src/renderer/src/components/editor/monaco-e2e-probe.ts @@ -1,6 +1,7 @@ import type { editor, IRange, ISelection } from 'monaco-editor' export type MonacoE2ESnapshot = { + canUndo: boolean contentHeight: number scrollHeight: number scrollTop: number @@ -74,6 +75,7 @@ export function installMonacoE2EProbe( ? lastLine : (model?.getLineContent(lastLineNumber - 1) ?? '') return { + canUndo: model?.canUndo() ?? false, contentHeight: editorInstance.getContentHeight(), scrollHeight: editorInstance.getScrollHeight(), scrollTop: editorInstance.getScrollTop(), diff --git a/tests/e2e/agent-session-log-tail-stability.spec.ts b/tests/e2e/agent-session-log-tail-stability.spec.ts index 01ef02bcf8e..e631a77d197 100644 --- a/tests/e2e/agent-session-log-tail-stability.spec.ts +++ b/tests/e2e/agent-session-log-tail-stability.spec.ts @@ -66,6 +66,7 @@ test.describe('Agent Session History live log', () => { // metrics (macOS baseline 2,775,880px; Linux CI ~2,498,292px), so keep only a // generous content-height floor as a collapsed/truncated-render smoke check. expect(baseline.valueLength).toBe(fixture.initialLength) + expect(baseline.canUndo).toBe(false) expect(baseline.contentHeight).toBeGreaterThanOrEqual(2_000_000) expect(baseline.visibleRanges.length).toBeGreaterThan(0) expect(baseline.find).toMatchObject({ open: true, query: ANCHOR_TOKEN }) @@ -105,6 +106,7 @@ test.describe('Agent Session History live log', () => { const current = await readProbe(orcaPage) console.log(`[live-log-stability] anchor ${JSON.stringify({ batch, baseline, current })}`) expect(current.visibleRanges).toEqual(baseline.visibleRanges) + expect(current.canUndo).toBe(false) expect(current.valueTail).toBe(suffix.trimEnd()) expect(current.selection).toEqual(baseline.selection) expect(current.find).toEqual(baseline.find) @@ -173,14 +175,9 @@ async function seedSyntheticSession( } async function openSessionHistory(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - store.getState().setRightSidebarOpen(true) - store.getState().setRightSidebarTab('vault') - }) + // Why: startup hydration can overwrite a direct store route; use the same + // activity-bar action a user takes so the Agents panel wins that race. + await page.getByRole('button', { name: 'Agents', exact: true }).click() await page.evaluate(async () => window.api.aiVault.listSessions({ force: true })) await page.getByRole('button', { name: 'Refresh Session History' }).click() } @@ -233,6 +230,7 @@ async function restoreAnchorState( } async function readProbe(page: Page): Promise<{ + canUndo: boolean contentHeight: number filePath: string scrollTop: number From a68bb39ab13ea814f143ce1204321c9233c590af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:18:37 -0700 Subject: [PATCH 39/54] =?UTF-8?q?perf(emulator):=20stop=20serve-sim=20watc?= =?UTF-8?q?her=20waking=20the=20daemon=204=C3=97/sec=20when=20unused=20(#8?= =?UTF-8?q?525)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Orca --- .../emulator/serve-sim-state-watcher.test.ts | 33 +++++++++++++++++-- src/main/emulator/serve-sim-state-watcher.ts | 26 +++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 69b57d60b3b..e528ea324bb 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -2,7 +2,11 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher' +import { + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, + ServeSimStateWatcher, + type ServeSimStateDetectedEvent +} from './serve-sim-state-watcher' const TEST_UDID = '11111111-2222-3333-4444-555555555555' @@ -45,7 +49,9 @@ describe('ServeSimStateWatcher', () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) cleanupPaths.push(parentDir) const stateDir = join(parentDir, 'serve-sim') - const watcher = new ServeSimStateWatcher({ stateDir }) + // Force darwin (the poll is macOS-only) and a fast interval so this + // cross-platform test exercises the existence-poll -> attach path quickly. + const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) const events: ServeSimStateDetectedEvent[] = [] watcher.bindPty('pty-1', 'worktree-1') @@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + it('does not arm the existence poll on non-macOS platforms', () => { + // serve-sim state never appears off macOS, so start() must not leave a + // recurring timer waking the daemon for a directory that can never exist. + for (const platform of ['win32', 'linux'] as const) { + const watcher = new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), + platform + }) + watcher.start() + const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll + expect(poll).toBeNull() + watcher.stop() + } + }) + + it('defaults the existence poll to a coarse (battery-friendly) interval', () => { + expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) + const watcher = new ServeSimStateWatcher() + expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS + ) + }) + it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { const watcher = createIsolatedWatcher() const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index 1a42b2d0353..dae8e57bc39 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = { } const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') +// Why: this only waits for a rarely-created dir to appear; sub-second detection +// of a detached emulator is not user-perceptible, so a coarse interval avoids +// waking the daemon 4x/sec for the whole session on machines that never use it. +export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g @@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string { export class ServeSimStateWatcher { private readonly stateDir: string + private readonly platform: NodeJS.Platform + private readonly existencePollMs: number private readonly ptyToWorktree = new Map() private readonly ptyBuffers = new Map() private readonly seenExternalKeys = new Set() @@ -81,8 +87,12 @@ export class ServeSimStateWatcher { private stateWatcher: FSWatcher | null = null private stateDirPoll: ReturnType | null = null - constructor(options: { stateDir?: string } = {}) { + constructor( + options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} + ) { this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR + this.platform = options.platform ?? process.platform + this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS } onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { @@ -171,6 +181,13 @@ export class ServeSimStateWatcher { if (this.stateDirPoll || this.stateWatcher) { return } + // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, + // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the + // existence poll there instead of waking the daemon every interval for a + // directory that can never exist. + if (this.platform !== 'darwin') { + return + } try { // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR @@ -182,13 +199,16 @@ export class ServeSimStateWatcher { return } + // attachStateDirWatch() clears this poll once the dir appears and the + // native watcher takes over, so it only ticks while waiting for a + // rarely-created dir — a coarse interval keeps that wait off the idle floor. this.stateDirPoll = setInterval(() => { this.attachStateDirWatch() this.scanExistingStateFiles() - }, 250) + }, this.existencePollMs) this.stateDirPoll.unref?.() } catch { - // Non-mac or permission issues: watcher is best-effort. + // Permission issues: watcher is best-effort. } } From 6e1aa8503c688f85d16b4c72981e2f5e862e491f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:19:17 -0700 Subject: [PATCH 40/54] perf(renderer): pause always-on idle timers while the window is hidden (#8528) Co-authored-by: Orca --- .../src/components/automations/AutomationsPage.tsx | 8 ++++++-- .../components/status-bar/WorkspaceSpaceManagerPanel.tsx | 7 +++++-- .../worktree-creation/WorktreeCreationPanel.tsx | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 305e89ba35b..98a78ef9845 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { ContextMenu, ContextMenuContent, @@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element { }, [fetchAllWorktrees, refresh]) useEffect(() => { - const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000) - return () => window.clearInterval(timer) + // Pause the relative-time clock while the window is hidden. + return installWindowVisibilityInterval({ + run: () => setRelativeNow(Date.now()), + intervalMs: 60 * 1000 + }) }, []) useEffect(() => { diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 734a531fc01..dcfce654488 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -35,6 +35,7 @@ import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' import { cn } from '@/lib/utils' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { toast } from 'sonner' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '../../store' @@ -321,9 +322,11 @@ function UpdatedMetric({ if (scannedAt === null) { return } + // Refresh once immediately (unconditional, as before) so a rescan that lands + // while hidden isn't shown stale, then pause the ongoing 60s tick while the + // window is hidden — same visibility-gated pattern as useNow. setNow(Date.now()) - const timer = window.setInterval(() => setNow(Date.now()), 60_000) - return () => window.clearInterval(timer) + return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 60_000 }) }, [scannedAt]) return ( diff --git a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx index 3a167caf757..c56d78a44e0 100644 --- a/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx +++ b/src/renderer/src/components/worktree-creation/WorktreeCreationPanel.tsx @@ -1,6 +1,7 @@ import React from 'react' import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react' import { useAppStore } from '@/store' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { getCreationProgressLabel } from '@/lib/pending-worktree-creation' import { translate } from '@/i18n/i18n' @@ -31,8 +32,9 @@ export default function WorktreeCreationPanel({ if (entryStatus !== 'creating') { return } - const timer = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(timer) + // Pause the 1s clock while the window is hidden so a backgrounded creation + // panel stops re-rendering for ticks no one can see. + return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 1000 }) }, [entryStatus]) if (!entry) { return null From 1d2aaf1bf52bde008561b36414548c62feb05ca4 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:41:24 -0700 Subject: [PATCH 41/54] Fix recipe serve desktop promotion (#8646) * fix(runtime): preserve terminals during headless desktop activation * rm design doc * Fix desktop activation launch ordering and blocked-window status resolut - Check desktopWindowStatus before spawning the Orca app so a blocked runtime no longer launches a doomed second instance. - Reuse resolveDesktopWindowStatus for remote runtime status so it honors the same authoritativeWindowId fallback as local status. - Re-check the authoritative window at spawn time instead of trusting a possibly-stale snapshot, since it can be destroyed mid-await. - Harden the e2e activation spec against silent spawn failures. --------- Co-authored-by: bbingz --- config/reliability-gates.jsonc | 159 +++++++++++++++ src/cli/format.ts | 1 + src/cli/runtime-client.test.ts | 91 ++++++++- src/cli/runtime/client.ts | 35 +++- src/cli/runtime/status.ts | 17 +- src/cli/runtime/websocket-transport.test.ts | 32 ++- src/main/index.ts | 121 +++++------ src/main/runtime/orca-runtime.test.ts | 174 +++++++++++++++- src/main/runtime/orca-runtime.ts | 118 ++++++++++- src/main/ssh/ssh-remote-cli-format.ts | 1 + src/main/ssh/ssh-remote-orca-cli.ts | 6 +- .../serve-desktop-activation-wiring.test.ts | 49 +++++ .../startup/serve-desktop-activation.test.ts | 54 +++++ src/main/startup/serve-desktop-activation.ts | 56 ++++++ src/main/startup/single-instance-lock.test.ts | 19 ++ src/main/startup/single-instance-lock.ts | 10 + .../window-all-closed-quit-policy.test.ts | 10 + .../startup/window-all-closed-quit-policy.ts | 2 +- src/shared/runtime-types.ts | 8 + .../headless-serve-desktop-activation.spec.ts | 188 ++++++++++++++++++ 20 files changed, 1075 insertions(+), 76 deletions(-) create mode 100644 src/main/startup/serve-desktop-activation-wiring.test.ts create mode 100644 src/main/startup/serve-desktop-activation.test.ts create mode 100644 src/main/startup/serve-desktop-activation.ts create mode 100644 tests/e2e/headless-serve-desktop-activation.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 64ac34ed173..5307bc71292 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -160,6 +160,165 @@ ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, + { + "id": "runtime.headless-desktop-promotion-continuity", + "title": "Headless serve opens its desktop without replacing live terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "headless orca serve", + "single-instance desktop activation", + "CLI open", + "persistent terminal reattach" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8457" + ], + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.", + "oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec electron-vite build --mode e2e", + "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/startup/serve-desktop-activation.test.ts", + "src/main/startup/serve-desktop-activation-wiring.test.ts", + "src/main/startup/single-instance-lock.test.ts", + "src/main/startup/window-all-closed-quit-policy.test.ts", + "src/cli/runtime-client.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/startup/serve-desktop-activation.test.ts", + "assertions": [ + "early activation requests coalesce until the persistent provider is ready", + "a blocked provider drops pending activation and never opens a window" + ] + }, + { + "file": "src/main/startup/serve-desktop-activation-wiring.test.ts", + "assertions": [ + "second-instance and macOS app activation use the same safety gate", + "headless PTY registration waits for provider settlement and promotion waits for RPC startup" + ] + }, + { + "file": "src/main/startup/single-instance-lock.test.ts", + "assertions": [ + "serve never skips the single-instance lock even in development", + "the isolated E2E profile can opt into the production ownership path" + ] + }, + { + "file": "src/main/startup/window-all-closed-quit-policy.test.ts", + "assertions": [ + "a promoted serve owner remains alive after an ordinary window close but exits after a committed quit" + ] + }, + { + "file": "src/cli/runtime-client.test.ts", + "assertions": [ + "local open activates a reachable headless owner and waits for a desktop window", + "unsafe promotion returns an explicit blocked error instead of launching a second owner" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "remote-paired open reports remote desktop state without launching a local app" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless sentinel transfers authority to the first real window", + "headless local and SSH PTY bindings are persisted on first promotion and later windowless reattach without changing ordinary desktop spawn persistence", + "status distinguishes available, openable, initializing, and blocked desktop states", + "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id", + "terminal output written before promotion remains visible and post-promotion input/output still works", + "the activating second process exits instead of becoming another owner" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13, + "summary": "Seven activation, ownership, quit, local/remote CLI, and runtime contract files passed with 704 tests, including first and repeated windowless reattach, local/SSH identity transfer, ordinary desktop persistence isolation, and dynamic side-effect scanner gating." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 52, + "summary": "The isolated Electron journey passed repeatedly on the final source; the latest 51.6-second run retained the same main owner, runtime, daemon, and PTY, restored pre-promotion output, accepted post-promotion input, and observed the activating process exit." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit contracts plus one isolated Electron headless-to-desktop journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic contracts and two consecutive local macOS Electron passes; CI and cross-platform soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added." + }, + "performanceBudget": { + "required": false, + "evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Add live packaged activation coverage on macOS plus representative Linux and Windows single-instance journeys.", + "Add an Electron SSH promotion journey in addition to the deterministic identity-transfer unit contract." + ], + "knownGaps": [ + "The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.", + "Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.", + "SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider." + ], + "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", diff --git a/src/cli/format.ts b/src/cli/format.ts index 80c621418e8..9d608a5b0f4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string { return [ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 7243d19fa94..c11292453c1 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' +import { launchOrcaApp } from './runtime/launch' + +vi.mock('./runtime/launch', () => ({ + launchOrcaApp: vi.fn() +})) const servers = new Set>() const sockets = new Set() afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() for (const socket of sockets) { socket.destroy() } @@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.graph.state).toBe('unavailable') }) - it('openOrca succeeds immediately when the runtime is already reachable', async () => { + it('openOrca activates the app even when a desktop runtime is already reachable', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { @@ -204,6 +210,87 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.runtime.state).toBe('ready') expect(status.result.runtime.reachable).toBe(true) + expect(launchOrcaApp).toHaveBeenCalledOnce() + }) + + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + let statusRequests = 0 + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + statusRequests += 1 + const available = statusRequests > 1 + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: available ? 1 : 0, + graphStatus: available ? 'reloading' : 'ready', + authoritativeWindowId: available ? 1 : 0, + desktopWindowStatus: available ? 'available' : 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + const status = await client.openOrca(1_000) + + expect(launchOrcaApp).toHaveBeenCalledOnce() + expect(status.result.app.desktopWindowStatus).toBe('available') + expect(statusRequests).toBeGreaterThan(1) + }) + + it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'blocked', + liveTabCount: 1, + liveLeafCount: 1 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'desktop_activation_blocked' + }) + // A blocked runtime can't promote, so we bail before spawning the app. + expect(launchOrcaApp).not.toHaveBeenCalled() }) it('times out if the runtime never responds', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9211173be2..db0744aaa86 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus } from './status' +import { getCliStatus, resolveDesktopWindowStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { sendWebSocketRequest } from './websocket-transport' @@ -115,7 +115,13 @@ export class RuntimeClient { // that this client machine has a local Orca desktop process. app: { running: false, - pid: null + pid: null, + // Why: reuse the shared resolver so remote status honors the same + // authoritativeWindowId fallback as local status for old runtimes. + ...(() => { + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) + return desktopWindowStatus ? { desktopWindowStatus } : {} + })() }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -169,15 +175,27 @@ export class RuntimeClient { async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { + if (this.remotePairing) { return initial } + // Why: a blocked runtime can't open a window, so spawning the app would + // only hit the single-instance lock and exit — bail before launching. + if (initial.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } launchOrcaApp() + if (initial.result.app.desktopWindowStatus === 'available') { + return initial + } + const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() - if (status.result.runtime.reachable) { + if (status.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status.result.app.desktopWindowStatus === 'available') { return status } await delay(250) @@ -185,11 +203,18 @@ export class RuntimeClient { throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' ) } } +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + function resolveRemotePairing( userDataPath: string, pairingCode: string | null, diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index dff8c231fc9..189297bf05f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -35,10 +35,12 @@ export async function getCliStatus( throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ app: { running: true, - pid: metadata.pid + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -68,6 +70,19 @@ export async function getCliStatus( } } +export function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 6a60c97b310..364fc05c508 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { @@ -13,6 +13,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { @@ -20,6 +21,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +vi.mock('./launch', () => ({ + launchOrcaApp: vi.fn() +})) + type TestRuntime = { endpoint: string publicKeyB64: string @@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => { const servers: TestRuntime[] = [] afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() await Promise.all(servers.splice(0).map((server) => server.close())) }) @@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) + it('does not launch a local desktop app for remote-paired open', async () => { + const runtime = await startTestRuntime('runtime-remote-headless', { + desktopWindowStatus: 'initializing' + }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + const status = await client.openOrca() + + expect(status.result.app.desktopWindowStatus).toBe('initializing') + expect(launchOrcaApp).not.toHaveBeenCalled() + }) + it('connects through a saved environment selector', async () => { const runtime = await startTestRuntime('runtime-env-1') servers.push(runtime) @@ -128,6 +156,7 @@ async function startTestRuntime( statusOverrides: { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number + desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -177,6 +206,7 @@ async function startTestRuntime( rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, + desktopWindowStatus: statusOverrides.desktopWindowStatus, liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: diff --git a/src/main/index.ts b/src/main/index.ts index 211bd473653..eb2ac4a7668 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,7 +92,8 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, - shouldBypassSingleInstanceLock + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -103,6 +104,7 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -183,6 +185,11 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../shared/runtime-types' +import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -244,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const desktopActivationGate = createServeDesktopActivationGate({ + initialState: isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) +}) // Why: on Windows a CLI-shaped launch (Orca.exe ) that lost // ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance // lock to a running window, and exit silently. Redirect it to node mode here, @@ -461,6 +478,23 @@ function focusExistingWindow(): void { }) } +function requestDesktopActivation(): void { + desktopActivationGate.requestActivation() +} + +function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const state = desktopActivationGate.getState() + return state === 'ready' ? 'openable' : state +} + +function settleServeDesktopActivation(): void { + if (getLocalPtyProvider() instanceof LocalPtyProvider) { + desktopActivationGate.markBlocked('persistent PTY provider unavailable') + return + } + desktopActivationGate.markReady() +} + // Why: a webContents-scoped flag that auto-expires so an intent set for one renderer // can't leak to a later load. `consume` clears on a positive match for one-shot // signals (the recovery reload fires exactly one did-finish-load). @@ -551,22 +585,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode }) +const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) if (bypassSingleInstanceLock) { // Why: this is an explicit diagnostic escape hatch for macOS builds where // Electron reports a false lock loss before any normal app logs exist. logSingleInstanceLockBypass() } -const hasSingleInstanceLock = - is.dev && !isServeMode +const hasSingleInstanceLock = skipSingleInstanceLock + ? true + : bypassSingleInstanceLock ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, focusExistingWindow) + : acquireSingleInstanceLock(app, requestDesktopActivation) if (startupDiagnosticsEnabled) { logStartupDiagnostic('single-instance-lock-result', { acquired: hasSingleInstanceLock, bypassed: bypassSingleInstanceLock, - skippedForDev: is.dev && !isServeMode + skippedForDev: skipSingleInstanceLock }) } if (!hasSingleInstanceLock) { @@ -633,12 +670,11 @@ ipcMain.handle( } ) -function startDesktopFirstWindowStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` - // registers its PTY runtime separately and must not spawn the desktop daemon - // or hook loopback listener. + // Why: desktop and headless serve must adopt the same persistent provider + // before either path is allowed to create terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) await initDaemonPtyProvider(signal) @@ -647,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise { // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so // the renderer awaits this barrier before restored terminals reconnect. startAgentHookServer: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -687,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise { return firstWindowStartupServicesReady } -async function startServeAgentHookServer(): Promise { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - try { - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - } catch (error) { - // Why: remote hook callbacks enrich agent status only. A headless runtime - // should still serve terminals if the loopback receiver cannot bind. - console.error('[agent-hooks] Failed to start serve hook server:', error) - } -} - function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hookTarget = @@ -1791,20 +1813,14 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, - // Why: derived title/bell/agent facts ride one batched main→renderer - // channel (terminal-side-effect-authority.md). The renderer's authority - // kill switch decides whether to consume. Headless serve never creates a - // window, so the dep is omitted entirely — the runtime then skips fact - // batch construction and the per-chunk bell walk. - ...(isServeMode - ? {} - : { - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - } - }), + // Why: serve can be promoted in place, so keep the listener wired from + // startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus: getDesktopWindowStatus, // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), @@ -2095,9 +2111,8 @@ app.whenReady().then(async () => { }) registerMobileHandlers(runtimeRpc) - if (!isServeMode) { - startDesktopFirstWindowStartupServices() - } + startTerminalRuntimeStartupServices() + app.on('activate', requestDesktopActivation) if (serveOptions) { // Why: give managed WSL launchers a brief chance to migrate before headless @@ -2107,7 +2122,9 @@ app.whenReady().then(async () => { logStartupMilestone('wsl-cli-barrier-resolved', { reconciliation: managedWslCliReconciliationStatus }) - await startServeAgentHookServer() + // Why: headless PTYs must never start on the fallback provider and then be + // swept when an activated renderer registers desktop lifecycle handlers. + await localPtyStartupReady registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, @@ -2125,11 +2142,12 @@ app.whenReady().then(async () => { // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + settleServeDesktopActivation() installServeSignalHandlers() // Why: the orca CLI command is normally installed by the renderer onboarding / // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no @@ -2212,15 +2230,6 @@ app.whenReady().then(async () => { triggerStartupNotificationRegistration(store) } }) - - app.on('activate', () => { - // Don't re-open a window while Squirrel's ShipIt is replacing the .app - // bundle. Without this guard the old version gets resurrected and the - // update never applies. - if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) { - openMainWindow() - } - }) }) app.on('before-quit', () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 96ba22d5cea..e7b11ef8bb2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,7 +56,10 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -1454,6 +1457,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable', authoritativeWindowId: null, + desktopWindowStatus: 'openable', rendererGraphEpoch: 0 }) expect(runtime.getRuntimeId()).toBeTruthy() @@ -1548,6 +1552,118 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID) }) + it('transfers authority from the headless sentinel to the first real window', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.attachWindow(TEST_WINDOW_ID) + runtime.attachWindow(2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + desktopWindowStatus: 'available', + graphStatus: 'reloading', + rendererGraphEpoch: 1 + }) + }) + + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('marks live bindings again when reopening after a promoted window closes', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + ;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({ + ...getSession(), + activeWorktreeIdsOnShutdown: [] + }) + runtime.markGraphUnavailable(TEST_WINDOW_ID) + + runtime.attachWindow(2) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('preserves live SSH session identities when promoting a headless runtime', () => { + const remotePtyId = 'ssh:ssh-1@@persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [], + activeConnectionIdsAtShutdown: [], + remoteSessionIdsByTabId: {}, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: remotePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession()).toMatchObject({ + activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID], + activeConnectionIdsAtShutdown: ['ssh-1'], + remoteSessionIdsByTabId: { 'host-tab': remotePtyId } + }) + }) + + it('reports the activation gate state while no desktop window is available', () => { + const runtime = new OrcaRuntimeService(store, undefined, { + getDesktopWindowStatus: () => 'blocked' + }) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus().desktopWindowStatus).toBe('blocked') + }) + it('bumps the epoch and enters reloading when the authoritative window reloads', () => { const runtime = createRuntime() @@ -6875,6 +6991,34 @@ describe('OrcaRuntimeService', () => { return { runtime, batches } } + it('defers desktop-only output scanners until a headless runtime is promoted', () => { + const { runtime, batches } = createSideEffectRuntime() + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.onPtyData('pty-1', '\x07', 100) + + expect(batches).toEqual([]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty-1', '\x07', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull() + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-1', '\x07', 102) + + expect(batches).toHaveLength(1) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9932,11 +10076,37 @@ describe('OrcaRuntimeService', () => { }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - worktreeId: TEST_WORKTREE_ID + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true }) ) }) + it('keeps ordinary desktop background terminal persistence opt-in', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const webContents = { send: vi.fn() } + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const spawnOptions = spawn.mock.calls[0]?.[0] as + | { persistHostSessionBinding?: boolean } + | undefined + expect(spawnOptions?.persistHostSessionBinding).toBeUndefined() + }) + it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6f1ca862fbe..96e73e3f965 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -171,6 +171,10 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -2502,8 +2506,10 @@ export class OrcaRuntimeService { private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2537,6 +2543,7 @@ export class OrcaRuntimeService { // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } ) { this.store = store @@ -2563,6 +2570,7 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -2965,6 +2973,7 @@ export class OrcaRuntimeService { rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -3079,11 +3088,78 @@ export class OrcaRuntimeService { } attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.authoritativeWindowId = windowId + return + } if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } + private persistWindowlessPtyBindingsForDesktopAttach(): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const promotablePtys = [...this.ptysById.values()].filter((pty) => { + if (!pty.connected || !pty.tabId) { + return false + } + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + return false + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) + }) + if (promotablePtys.length === 0) { + return + } + + // Why: renderer hydration treats an explicitly-present shutdown list as + // authoritative. A windowless owner has no renderer shutdown pass, so seed + // that existing reattach contract before its next desktop window loads. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...promotablePtys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...promotablePtys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of promotablePtys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession({ + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }) + } + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId @@ -3208,6 +3284,7 @@ export class OrcaRuntimeService { this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) @@ -5985,7 +6062,7 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.onTerminalSideEffects) { + if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -6001,7 +6078,11 @@ export class OrcaRuntimeService { facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { - if (!this.onTerminalSideEffects || facts.length === 0) { + if ( + !this.onTerminalSideEffects || + !this.terminalSideEffectConsumerAvailable || + facts.length === 0 + ) { return } const batch: TerminalSideEffectBatch = { @@ -6180,7 +6261,7 @@ export class OrcaRuntimeService { // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. - ...(this.onTerminalSideEffects + ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -6213,7 +6294,7 @@ export class OrcaRuntimeService { // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.onTerminalSideEffects + commandCodeDetector: this.terminalSideEffectConsumerAvailable ? createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { @@ -6312,6 +6393,19 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + const nextAvailable = available && this.onTerminalSideEffects !== null + 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) + } + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -17578,12 +17672,12 @@ export class OrcaRuntimeService { ): Promise { const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. - const rendererWindow = - opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && ((!requiresRendererFocus && opts.rendererBacked !== true) || @@ -17704,7 +17798,14 @@ export class OrcaRuntimeService { tabId, leafId, ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) + // Why: a headless-created pane has no renderer session writer. Persist + // its tab/leaf binding at spawn so a later promoted window reattaches + // the live daemon or SSH PTY instead of replacing it with a fresh one. + // Re-check freshly: the entry-time snapshot can go stale across the + // awaits above if the authoritative window is destroyed mid-spawn. + ...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null + ? { persistHostSessionBinding: true } + : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) this.registerPty(result.id, workspace.id, workspace.connectionId) @@ -19049,6 +19150,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() @@ -19065,6 +19167,7 @@ export class OrcaRuntimeService { return } this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } @@ -19078,6 +19181,7 @@ export class OrcaRuntimeService { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index c654af79b74..744c9f8e6cf 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: stdout: `${[ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 205723321da..e43956dfc1c 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -158,7 +158,11 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { running: true, pid: null }, + app: { + running: true, + pid: null, + ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) + }, runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts new file mode 100644 index 00000000000..9008ef4014e --- /dev/null +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('serve desktop activation wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('routes second-instance and app activation through one safety gate', () => { + expect(source).toContain('createServeDesktopActivationGate({') + expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') + expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + }) + + it('settles the persistent provider before headless PTY registration', () => { + const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') + const startupIndex = source.indexOf( + '\n startTerminalRuntimeStartupServices()\n', + appReadyIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) + const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + + expect(startupIndex).toBeGreaterThanOrEqual(0) + expect(startupIndex).toBeLessThan(serveIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(source).not.toContain( + 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' + ) + }) + + it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const sentinelIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(serveIndex).toBeGreaterThanOrEqual(0) + expect(sentinelIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(sentinelIndex) + expect(settleIndex).toBeGreaterThan(rpcIndex) + expect(source).not.toContain('runtime.syncWindowGraph(0,') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.test.ts b/src/main/startup/serve-desktop-activation.test.ts new file mode 100644 index 00000000000..659e7b382d4 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createServeDesktopActivationGate } from './serve-desktop-activation' + +describe('createServeDesktopActivationGate', () => { + it('coalesces activation requests while serve is initializing and drains once when ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(gate.getState()).toBe('initializing') + + gate.markReady() + + expect(activateWindow).toHaveBeenCalledOnce() + expect(gate.getState()).toBe('ready') + }) + + it('activates immediately after the persistent provider is ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).toHaveBeenCalledTimes(2) + }) + + it('drops pending activation and fails closed when promotion is blocked', () => { + const activateWindow = vi.fn() + const onBlocked = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow, + onBlocked + }) + + gate.requestActivation() + gate.markBlocked('persistent PTY provider unavailable') + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(onBlocked).toHaveBeenCalledTimes(2) + expect(gate.getState()).toBe('blocked') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts new file mode 100644 index 00000000000..8ee6a83a0e8 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.ts @@ -0,0 +1,56 @@ +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' + +type ActivationGateState = Exclude | 'ready' + +export type ServeDesktopActivationGate = { + getState: () => ActivationGateState + requestActivation: () => void + markReady: () => void + markBlocked: (reason: string) => void +} + +export function createServeDesktopActivationGate(options: { + initialState: 'initializing' | 'ready' + activateWindow: () => void + onBlocked?: (reason: string) => void +}): ServeDesktopActivationGate { + let state: ActivationGateState = options.initialState + let pendingActivation = false + let blockedReason = 'desktop activation is unavailable' + + return { + getState: () => state, + requestActivation: () => { + if (state === 'ready') { + options.activateWindow() + return + } + if (state === 'initializing') { + pendingActivation = true + return + } + options.onBlocked?.(blockedReason) + }, + markReady: () => { + if (state !== 'initializing') { + return + } + state = 'ready' + if (pendingActivation) { + pendingActivation = false + options.activateWindow() + } + }, + markBlocked: (reason) => { + if (state !== 'initializing') { + return + } + state = 'blocked' + blockedReason = reason + if (pendingActivation) { + pendingActivation = false + options.onBlocked?.(blockedReason) + } + } + } +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index f5318b7b54b..5ccd70e6a4a 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -5,6 +5,7 @@ import { logSingleInstanceLockBypass, logSingleInstanceLockFailure, shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => { }) }) +describe('shouldSkipSingleInstanceLock', () => { + it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + }) + + it('lets isolated E2E exercise the production single-instance path', () => { + expect( + shouldSkipSingleInstanceLock({ + isDev: true, + isServeMode: false, + env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + }) + ).toBe(false) + }) +}) + describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 5fd9cc27610..078500284a2 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' @@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: { ) } +export function shouldSkipSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean +}): boolean { + const env = options.env ?? process.env + return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' +} + export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) } diff --git a/src/main/startup/window-all-closed-quit-policy.test.ts b/src/main/startup/window-all-closed-quit-policy.test.ts index b7aab9e13ae..6c451f7e4ad 100644 --- a/src/main/startup/window-all-closed-quit-policy.test.ts +++ b/src/main/startup/window-all-closed-quit-policy.test.ts @@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => { }) ).toBe(true) }) + + it('continues a committed quit after a serve owner was promoted to desktop', () => { + expect( + shouldQuitWhenAllWindowsClosed({ + platform: 'darwin', + isQuitting: true, + isServeMode: true + }) + ).toBe(true) + }) }) diff --git a/src/main/startup/window-all-closed-quit-policy.ts b/src/main/startup/window-all-closed-quit-policy.ts index 8b23f436140..f17881ea989 100644 --- a/src/main/startup/window-all-closed-quit-policy.ts +++ b/src/main/startup/window-all-closed-quit-policy.ts @@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: { isQuitting: boolean isServeMode: boolean }): boolean { - if (options.isServeMode) { + if (options.isServeMode && !options.isQuitting) { return false } return options.platform !== 'darwin' || options.isQuitting diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 028327eb226..41a800eb3da 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -37,6 +37,12 @@ export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult } export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' +export type RuntimeDesktopWindowStatus = 'available' | 'openable' | 'initializing' | 'blocked' + +// Why: headless serve still owns one runtime graph, but zero can never collide +// with Electron BrowserWindow ids and can be transferred safely on promotion. +export const HEADLESS_RUNTIME_WINDOW_ID = 0 + // Why: the access scope a paired device token grants. Lives in shared so // pairing offers, status.get, and the device registry use one vocabulary. export type DeviceScope = 'mobile' | 'runtime' @@ -55,6 +61,7 @@ export type RuntimeStatus = { rendererGraphEpoch: number graphStatus: RuntimeGraphStatus authoritativeWindowId: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus liveTabCount: number liveLeafCount: number // Why: optional so clients can read both new and pre-contract runtimes. @@ -85,6 +92,7 @@ export type CliStatusResult = { app: { running: boolean pid: number | null + desktopWindowStatus?: RuntimeDesktopWindowStatus } runtime: { state: CliRuntimeState diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts new file mode 100644 index 00000000000..04d11db2768 --- /dev/null +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -0,0 +1,188 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { _electron as electron, type ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile' +import { getOrcaElectronLaunchArgs } from './helpers/electron-launch-args' +import { cleanupE2EDaemons, closeElectronAppForE2E } from './helpers/electron-process-shutdown' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime/client' +import type { + RuntimeStatus, + RuntimeTerminalCreate, + RuntimeTerminalRead +} from '../../src/shared/runtime-types' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' + +const electronPackageDir = path.join(process.cwd(), 'node_modules', 'electron') +const electronPath = path.join( + electronPackageDir, + 'dist', + readFileSync(path.join(electronPackageDir, 'path.txt'), 'utf8').trim() +) + +function createLaunchEnv(userDataDir: string): NodeJS.ProcessEnv { + const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env + void _unused + return { + ...cleanEnv, + NODE_ENV: 'development', + ORCA_E2E_USER_DATA_DIR: userDataDir, + ORCA_E2E_HEADLESS: '1', + // Why: production builds always use the lock; this opt-in makes the dev + // E2E bundle exercise the same second-instance ownership path. + ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' + } +} + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +async function waitForProcessExit(child: ChildProcess, timeoutMs: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return true + } + return await new Promise((resolve) => { + const onExit = (): void => { + clearTimeout(timeout) + resolve(true) + } + const timeout = setTimeout(() => { + child.off('exit', onExit) + resolve(false) + }, timeoutMs) + child.once('exit', onExit) + }) +} + +test.describe.configure({ mode: 'serial' }) + +test('promotes the headless owner without replacing its daemon terminal', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both launches and intentionally opts out of the default app fixture. +{}) => { + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') + const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-serve-promotion-')) + const env = createLaunchEnv(userDataDir) + let serveApp: ElectronApplication | null = null + let activatingProcess: ChildProcess | null = null + + writeFileSync( + path.join(userDataDir, 'orca-data.json'), + `${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n` + ) + + try { + serveApp = await electron.launch({ + args: [...getOrcaElectronLaunchArgs(mainPath, false), '--serve', '--serve-no-pairing'], + env + }) + const ownerPid = serveApp.process().pid + 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' + }) + .toBe('openable') + + const beforeStatus = await client.call('status.get') + const daemonPidBefore = readDaemonPid(userDataDir) + await client.call('repo.add', { path: repoPath, kind: 'git' }) + const created = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', { + worktree: `path:${repoPath}`, + 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 beforeMarker = `SERVE_PROMOTION_BEFORE_${Date.now()}` + await client.call('terminal.send', { + terminal: terminal.handle, + text: `echo ${beforeMarker}`, + enter: true + }) + await expect + .poll( + async () => { + const response = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: terminal.handle, + limit: 200 + }) + return response.result.terminal.tail.join('\n') + }, + { timeout: 15_000 } + ) + .toContain(beforeMarker) + + activatingProcess = spawn(electronPath, getOrcaElectronLaunchArgs(mainPath, false), { + env, + stdio: 'ignore' + }) + activatingProcess.on('error', (error) => { + console.error('[e2e] activating process failed to spawn:', error) + }) + + const page = await serveApp.firstWindow({ timeout: 60_000 }) + await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + + const promotedPtyId = await discoverActivePtyId(page) + const afterStatus = await client.call('status.get') + expect(serveApp.process().pid).toBe(ownerPid) + expect(afterStatus.result.runtimeId).toBe(beforeStatus.result.runtimeId) + expect(afterStatus.result.desktopWindowStatus).toBe('available') + expect(promotedPtyId).toBe(terminal.ptyId) + expect(readDaemonPid(userDataDir)).toBe(daemonPidBefore) + expect(await waitForProcessExit(activatingProcess, 10_000)).toBe(true) + await waitForTerminalOutput(page, beforeMarker, 30_000) + + const afterMarker = `SERVE_PROMOTION_AFTER_${Date.now()}` + await execInTerminal(page, promotedPtyId, `echo ${afterMarker}`) + await waitForTerminalOutput(page, afterMarker, 15_000) + await expect(page.locator('.xterm:visible').first()).toBeVisible() + expect(await getTerminalContent(page)).toContain(beforeMarker) + } finally { + if (activatingProcess && activatingProcess.exitCode === null) { + activatingProcess.kill('SIGKILL') + await waitForProcessExit(activatingProcess, 5_000) + } + if (serveApp) { + await closeElectronAppForE2E(serveApp) + } + await cleanupE2EDaemons(userDataDir) + rmSync(userDataDir, { recursive: true, force: true }) + } +}) From 8662e5a7ab448063f102888e7b00052cd6465080 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:46:08 -0700 Subject: [PATCH 42/54] perf(ssh): merge the two identical 5s keepalive timers into one per connection (#8653) Co-authored-by: Orca --- src/main/ssh/ssh-channel-multiplexer.test.ts | 19 +++++++++++ src/main/ssh/ssh-channel-multiplexer.ts | 34 +++++++++----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 10123d56a25..16de2b94a46 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => { expect(() => vi.advanceTimersByTime(5_000)).not.toThrow() expect(mux.isDisposed()).toBe(true) }) + + it('drives keepalive sends AND dead-link detection from a single interval', () => { + // The keepalive send and the liveness/timeout check were merged from two + // 5s intervals into one; there must be exactly one recurring timer. + expect(vi.getTimerCount()).toBe(1) + + // Send half: a keepalive is written on the tick. + const before = transport.written.length + vi.advanceTimersByTime(5_000) + expect(transport.written.length).toBeGreaterThan(before) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + expect(vi.getTimerCount()).toBe(1) + + // Check half: with no inbound frames or acks, the same interval declares + // the link dead (no-data + oldest-unacked both exceed the 20s window). + expect(mux.isDisposed()).toBe(false) + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) }) describe('wake guard (timer pause across system sleep, #7773)', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 8cb0a24f302..25e6e2c6f00 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -54,8 +54,7 @@ export class SshChannelMultiplexer { // generic notification listener that already serves fs.changed. private methodNotificationHandlers = new Map>() private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] - private keepaliveTimer: ReturnType | null = null - private timeoutTimer: ReturnType | null = null + private connectionHealthTimer: ReturnType | null = null private disposed = false // Track the oldest unacked outgoing message timestamp @@ -88,8 +87,7 @@ export class SshChannelMultiplexer { if (this.disposed) { return } - this.startKeepalive() - this.startTimeoutCheck() + this.startConnectionHealthTimer() } onNotification(handler: NotificationHandler): () => void { @@ -276,13 +274,9 @@ export class SshChannelMultiplexer { } this.disposed = true - if (this.keepaliveTimer) { - clearInterval(this.keepaliveTimer) - this.keepaliveTimer = null - } - if (this.timeoutTimer) { - clearInterval(this.timeoutTimer) - this.timeoutTimer = null + if (this.connectionHealthTimer) { + clearInterval(this.connectionHealthTimer) + this.connectionHealthTimer = null } // Why: the renderer uses the error code to distinguish temporary disconnects @@ -488,15 +482,17 @@ export class SshChannelMultiplexer { } } - private startKeepalive(): void { - this.keepaliveTimer = setInterval(() => { - this.sendKeepAlive() - }, KEEPALIVE_SEND_MS) - } - - private startTimeoutCheck(): void { + // Why: one 5s interval owns BOTH the periodic keepalive send and the + // liveness/dead-link check. They used to be two separate 5s intervals that + // always fired back-to-back (keepalive created first); folding them into one + // tick — send first (as the keepalive timer did), then run the check (as the + // timeout timer did) — halves the per-connection timer count with identical + // behavior, including the #7773 wake-gap recovery below. + private startConnectionHealthTimer(): void { let lastTickAt = Date.now() - this.timeoutTimer = setInterval(() => { + this.connectionHealthTimer = setInterval(() => { + this.sendKeepAlive() + if (this.disposed) { return } From 1ef0551bc138724d736583eac788f4183bef07e2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:10:50 -0700 Subject: [PATCH 43/54] Create pr not working for stacked worktree (#8651) * Fix stacked-worktree PR creation targeting a local-only parent branch - Resolve the eligibility default base to a remote-tracking ref instead of blindly trusting the submitted parent branch, since a stacked worktree's base is often a local-only branch the remote can't resolve - Add a create-time hard block (base_not_on_remote) so a stale or unpushed submitted base fails with actionable copy instead of the provider's opaque error - Update the dialog's default-base resolution and blocked-action/ dropdown copy to match the new remote-validated default * Split hosted-review-creation.test.ts to fix max-lines lint error Moved getHostedReviewCreationEligibility tests to a separate file (hosted-review-creation-eligibility.test.ts) to reduce the original file size from 880 to 579 lines, satisfying the max-lines lint constraint. Co-authored-by: Orca * Fix Create PR intent flow to use remote-validated eligibility default fo Prefer eligibilityDefaultBaseRef over the raw compare base when resolving the review base for the one-click Create PR intent flow, since eligibility is recomputed from the same compare base right before creation and already corrects a local-only stacked parent to the repo default. Falls back to the compare base only when eligibility supplies no default. * Simplify base-ref remote existence check into a single for-each-ref call Combine the wildcard and exact-tracking-ref lookups into one for-each-ref invocation with multiple patterns instead of two sequential git calls, removing the redundant rev-parse fallback path. --------- Co-authored-by: Orca --- src/cli/handlers/core.test.ts | 4 +- .../windows-firewall-remote-scope.test.ts | 10 +- ...hosted-review-creation-eligibility.test.ts | 529 ++++++++++++++++++ .../hosted-review-creation.test.ts | 282 ++-------- .../source-control/hosted-review-creation.ts | 108 +++- ...urce-control-create-pr-intent-flow.test.ts | 23 +- .../source-control-create-pr-intent-flow.ts | 11 +- ...ce-control-create-review-blocked-action.ts | 3 + .../source-control-dropdown-items.ts | 2 + ...control-primary-create-pr-intent-action.ts | 9 +- .../useCreatePullRequestDialogFields.test.ts | 28 +- .../useCreatePullRequestDialogFields.ts | 8 +- .../agent-completion-coordinator.test.ts | 21 +- ...gent-hook-completion-notifications.test.ts | 5 +- src/shared/hosted-review.ts | 4 + 15 files changed, 767 insertions(+), 280 deletions(-) create mode 100644 src/main/source-control/hosted-review-creation-eligibility.test.ts diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts index 69f27dc9bd6..ac5444566d9 100644 --- a/src/cli/handlers/core.test.ts +++ b/src/cli/handlers/core.test.ts @@ -64,7 +64,9 @@ describe('orca claude-teams CLI handler', () => { spawnMock.mockImplementation(() => mockClaudeChild()) callMock.mockReset() callMock.mockResolvedValue({ - result: { launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } } + result: { + launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } + } }) previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE previousPaneKey = process.env.ORCA_PANE_KEY diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts index 2c9a013d964..6e2462c0334 100644 --- a/src/main/runtime/windows-firewall-remote-scope.test.ts +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -126,16 +126,18 @@ describe('Windows firewall remote-address scope', () => { // A /32 subnet is just the desktop itself, so an explicit range cannot prove // the phone (a different host) is allowed — only the keywords can. expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) - expect(hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32)).toBe( - true - ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32) + ).toBe(true) expect( hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) ).toBe(false) }) it('fails address-family keywords closed when the interface family is unknown', () => { - expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe(true) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe( + true + ) expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( false ) diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts new file mode 100644 index 00000000000..010d950d50f --- /dev/null +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + createAzureDevOpsPullRequestMock: vi.fn(), + createGiteaPullRequestMock: vi.fn(), + isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(), + isGiteaReviewCreationAuthenticatedMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/pull-request-creation', () => ({ + createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock, + isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../gitea/pull-request-creation', () => ({ + createGiteaPullRequest: createGiteaPullRequestMock, + isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } +})) + +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock + ]) { + mock.mockReset() + } +} + +function mockGitHubProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) +} + +function mockGitLabProvider(): void { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockAzureDevOpsProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue({ + host: 'dev.azure.com', + project: 'Project', + repository: 'orca', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca' + }) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockGiteaProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'git.example.com', + owner: 'acme', + repo: 'orca', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) +} + +describe('getHostedReviewCreationEligibility', () => { + beforeEach(() => { + resetMocks() + + mockGitHubProvider() + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) + isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) + }) + + it('treats short remote base refs as the default branch name', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'main', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'default_branch', + defaultBaseRef: 'origin/main' + }) + }) + + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a + // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. + const stackedArgs = ( + overrides: Partial[0]> = {} + ): Parameters[0] => ({ + repoPath: '/repo', + branch: 'feature/stacked', + base: 'stacked-parent', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0, + ...overrides + }) + + const mockRefs = (opts: { + symbolicRef?: string + forEachRef?: string + forEachThrows?: boolean + revParseThrows?: boolean + }): void => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: opts.symbolicRef ?? '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + if (opts.forEachThrows) { + throw new Error('ssh: connect: connection refused') + } + return { stdout: opts.forEachRef ?? '', stderr: '' } + } + if (args[0] === 'rev-parse' && opts.revParseThrows) { + throw new Error('unknown revision') + } + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + } + + it('falls back to the repo default when a stacked parent base is local-only', async () => { + mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'origin/main' + }) + }) + + it('preserves a stacked parent base that exists on the remote', async () => { + mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'parent-pushed' + }) + }) + + it('keeps the candidate base when no repo default can be resolved', async () => { + mockRefs({ revParseThrows: true }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'stacked-parent' + }) + }) + + it('preserves the candidate base when the remote probe cannot reach the host', async () => { + // Transport failure must not be read as "absent" — that would demote a + // legitimately-pushed parent to the repo default on a transient SSH blip. + mockRefs({ forEachThrows: true }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' }) + }) + + it('blocks dirty tracked GitHub branches before PR creation', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { + getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'refs/heads/feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'origin/main', + head: 'feature/create-pr' + }) + }) + + it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null + }) + + // Enterprise auth was already confirmed during detection; the gate must not + // fire a redundant gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { + const remoteGit = { + exec: vi.fn(async () => ({ stdout: '', stderr: '' })) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + head: 'feature/create-pr' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) + ) + // Why: the base-on-remote probe must run on the SSH host that will execute + // the provider create, so it flows through the relay exec, not local git. + expect(remoteGit.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/remotes/*/main'], + '/remote/repo' + ) + }) + + it('offers push as the next action for authenticated branches with local-only commits', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { + mockGitLabProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitlab', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitlab' + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) + }) + + it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { + mockAzureDevOpsProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/azure', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'azure-devops', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/azure' + }) + expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { + mockGiteaProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitea', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitea', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitea' + }) + expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 3ae82fd4010..04c751d7c3b 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -115,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -223,6 +223,11 @@ describe('createHostedReview', () => { if (args[0] === 'status') { return { stdout: '', stderr: '' } } + // Why: base-on-remote probe (Change 2 enforcement) — the default base + // resolves to a remote-tracking branch so create-time validation passes. + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -301,6 +306,32 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) + it('blocks creation with actionable copy when the submitted base is local-only', async () => { + // for-each-ref falls through to '' → the submitted stacked parent is not on + // the remote, so create-time enforcement blocks with actionable copy. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'stacked-parent', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: + 'Create PR failed: the base branch "stacked-parent" hasn\'t been pushed to the remote. Choose a pushed base or push it first.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('creates the pull request after fresh main-process validation passes', async () => { await expect( createHostedReview('/repo', { @@ -501,6 +532,10 @@ describe('createHostedReview', () => { if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { return { stdout: 'feature\n', stderr: '' } } + if (args[0] === 'for-each-ref') { + // Base-on-remote probe (Change 2) runs on the SSH host; base is pushed. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -588,248 +623,3 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) }) - -describe('getHostedReviewCreationEligibility', () => { - beforeEach(() => { - resetMocks() - - mockGitHubProvider() - getHostedReviewForBranchMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) - isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) - isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) - }) - - it('treats short remote base refs as the default branch name', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'main', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'default_branch', - defaultBaseRef: 'origin/main' - }) - }) - - it('blocks dirty tracked GitHub branches before PR creation', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { - getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: false, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'refs/heads/feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: 'origin/main', - head: 'feature/create-pr' - }) - }) - - it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { - mockGitHubEnterpriseProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null - }) - - // Enterprise auth was already confirmed during detection; the gate must not - // fire a redundant gh probe. - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ stdout: '', stderr: '' })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/remote/repo', - connectionId: 'ssh-1', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - head: 'feature/create-pr' - }) - - expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) - ) - expect(remoteGit.exec).not.toHaveBeenCalled() - }) - - it('offers push as the next action for authenticated branches with local-only commits', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 2, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { - mockGitLabProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitlab', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitlab', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitlab' - }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'gitlab.com'], - { cwd: '/repo' } - ) - }) - - it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { - mockAzureDevOpsProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/azure', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'azure-devops', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/azure' - }) - expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { - mockGiteaProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitea', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitea', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitea' - }) - expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 01caac804d8..1e19f1d4fa8 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -42,6 +42,10 @@ import { type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null + // Why: only the create-time preflight enforces base-on-remote as a hard block; + // the renderer's eligibility probe passes the base as a candidate and relies on + // Change 1 to correct a local-only parent, so it must never set this. + enforceBaseOnRemote?: boolean } & HostedReviewExecutionOptions function stripRefPrefix(ref: string): string { @@ -133,6 +137,52 @@ async function getDefaultBaseRef( ) } +/** + * Whether the candidate base resolves to a remote-tracking branch on the + * executing host. + * + * Why: a stacked worktree's `worktree.baseRef` is typically a bare parent + * branch name with no remote qualifier, so the probe must match the branch + * under *any* configured remote rather than assume `origin` — otherwise fork + * workflows (`upstream/main`) would be missed. Runs through + * `runGitForHostedReview` so it evaluates on the same host that will run the + * provider create (native/WSL/SSH/relay). This reads the local remote-tracking + * snapshot, not the live remote; see the design doc's Open Questions for the + * ls-remote/staleness tradeoff left as a follow-up. + */ +async function baseRefExistsOnRemote( + candidate: string, + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const base = normalizeHostedReviewBaseRef(candidate).trim() + if (!base) { + return false + } + const run = (argv: string[]): Promise<{ stdout: string }> => + runGitForHostedReview(repoPath, argv, connectionId, options) + + const patterns = [`refs/remotes/*/${base}`] + // Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob + // above only matches a branch literally named that because `*` does not cross `/`. + // Include the exact tracking ref directly. + if (base.includes('/')) { + patterns.push(`refs/remotes/${base}`) + } + + try { + // for-each-ref exits 0 whether or not the pattern matches, so a clean empty + // result is an authoritative "absent" while a thrown error is a transport + // failure. Never conflate the two: on an unreachable host, preserve the + // candidate rather than silently demoting a legitimately-pushed parent base. + const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns]) + return stdout.trim().length > 0 + } catch { + return true + } +} + async function getCurrentBranch( repoPath: string, connectionId?: string | null, @@ -255,9 +305,11 @@ async function isProviderAuthenticated( function blockedCreateResultForReason( reason: NonNullable, - provider: HostedReviewProvider + provider: HostedReviewProvider, + submittedBase?: string | null ): CreateHostedReviewResult | null { const copy = reviewCopy(provider) + const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : '' const blockedCreateResultByReason = { auth_required: { ok: false, @@ -303,6 +355,11 @@ function blockedCreateResultForReason( ok: false, code: 'validation', error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` + }, + base_not_on_remote: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.` } } satisfies Partial< Record, CreateHostedReviewResult> @@ -311,7 +368,8 @@ function blockedCreateResultForReason( } function blockedEligibilityToCreateResult( - eligibility: HostedReviewCreationEligibility + eligibility: HostedReviewCreationEligibility, + submittedBase?: string | null ): CreateHostedReviewResult | null { if (eligibility.canCreate) { return null @@ -326,7 +384,11 @@ function blockedEligibilityToCreateResult( } } if (eligibility.blockedReason) { - return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider) + return blockedCreateResultForReason( + eligibility.blockedReason, + eligibility.provider, + submittedBase + ) } const copy = reviewCopy(eligibility.provider) return { @@ -358,20 +420,24 @@ async function validateCurrentBranchCanCreateReview( hasUncommittedChanges(repoPath, connectionId, options), getHostedReviewUpstreamStatus(repoPath, connectionId, options) ]) + const submittedBase = normalizeHostedReviewBaseRef(input.base) const eligibility = await getHostedReviewCreationEligibility({ repoPath, branch: requestedHead || currentBranch, - base: normalizeHostedReviewBaseRef(input.base), + base: submittedBase, hasUncommittedChanges: dirty, hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, connectionId, + // Why: this is the last gate before the provider create, which targets the + // submitted base verbatim — enforce that the base exists on the remote here. + enforceBaseOnRemote: true, ...options }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. - return blockedEligibilityToCreateResult(eligibility) + return blockedEligibilityToCreateResult(eligibility, submittedBase) } catch (error) { console.warn('Hosted review creation preflight failed:', error) return { @@ -391,8 +457,22 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId, ...hostedReviewExecutionContext(args) }) - const defaultBaseRef = - args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args)) + // Why: an incoming base is only a *candidate* for the default merge target. A + // stacked worktree's parent base resolves on the remote only when it was + // actually pushed; a local-only parent must fall back to the repo default so + // the PR targets a ref the remote can resolve. Never regress to "no base" — + // keep the candidate if the repo default itself is unavailable. + const candidateBase = args.base?.trim() || null + const candidateBaseOnRemote = + candidateBase != null && + (await baseRefExistsOnRemote(candidateBase, args.repoPath, args.connectionId, args)) + let defaultBaseRef: string | null + if (candidateBase && candidateBaseOnRemote) { + defaultBaseRef = candidateBase + } else { + const repoDefaultBaseRef = await getDefaultBaseRef(args.repoPath, args.connectionId, args) + defaultBaseRef = repoDefaultBaseRef ?? candidateBase + } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null try { @@ -481,6 +561,20 @@ export async function getHostedReviewCreationEligibility( if ((args.ahead ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } } + // Why: at create-time, `gh pr create` (and the other providers) target the + // submitted base verbatim — Change 1 only corrects the *default*, not a stale + // renderer's submitted value. Block a local-only submitted base here so it + // fails with actionable copy instead of the provider's opaque error. Only the + // create-time preflight enforces this; the renderer's eligibility probe leaves + // enforceBaseOnRemote unset so a local-only parent is silently auto-corrected. + if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'base_not_on_remote', + nextAction: null + } + } return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } } 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 74853dd4147..516c0711369 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 @@ -143,14 +143,18 @@ describe('source-control Create PR intent flow helpers', () => { ).toEqual(['safe.ts', 'new.ts']) }) - it('prefers the current compare base over stale eligibility defaults', () => { + it('prefers the remote-validated eligibility default so it cannot diverge from the composer', () => { + // Why: the intent flow's eligibility is recomputed from the same compare + // base right before creation, so its default already corrects a local-only + // stacked parent to the repo default. The one-click path must target that + // same base as the composer, not the raw (possibly unpushable) compare base. expect( resolveCreatePrIntentReviewBase({ - currentBaseRef: 'refs/remotes/origin/release', + currentBaseRef: 'stacked-parent', eligibilityDefaultBaseRef: 'refs/remotes/origin/main', composerBaseRef: 'main' }) - ).toBe('release') + ).toBe('main') expect( resolveCreatePrIntentReviewBase({ @@ -161,6 +165,19 @@ describe('source-control Create PR intent flow helpers', () => { ).toBe('develop') }) + it('falls back to the compare base when eligibility supplies no default', () => { + // Why: never blank the base. If the main process could not resolve a default + // (no candidate on remote and repo default unavailable), keep the user's + // compare base rather than dropping to an empty target. + expect( + resolveCreatePrIntentReviewBase({ + currentBaseRef: 'refs/remotes/origin/release', + eligibilityDefaultBaseRef: null, + composerBaseRef: 'main' + }) + ).toBe('release') + }) + it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => { expect( resolveCreatePrIntentRemoteStep({ 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 7e183122ec5..566ee54e1d7 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 @@ -103,10 +103,15 @@ export function resolveCreatePrIntentReviewBase({ eligibilityDefaultBaseRef?: string | null composerBaseRef?: string | null }): string { - // Why: the compare-base picker is the user's latest target; eligibility can - // lag behind while Create PR intent is preparing the branch. + // Why: prefer the remote-validated eligibility default over the raw compare + // base. The intent flow auto-submits, and its eligibility is recomputed from + // this same compare base right before creation — so `eligibilityDefaultBaseRef` + // already keeps a pushed base verbatim and corrects a local-only stacked parent + // to the repo default. Using it keeps the one-click path consistent with the + // composer instead of submitting a base the remote cannot resolve. Fall back to + // the compare base only when eligibility supplied no default. return normalizeHostedReviewBaseRef( - currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || composerBaseRef?.trim() || '' + eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || composerBaseRef?.trim() || '' ) } 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 e59354da5c8..2475a7d293e 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 @@ -66,6 +66,9 @@ export function resolveBlockedCreateReviewNoticeMessage( case 'existing_review': case 'fork_head_unsupported': case 'unsupported_provider': + // Why: base_not_on_remote is a create-time hard failure surfaced as an error + // result, not an inline-actionable eligibility state, so it is non-clickable. + case 'base_not_on_remote': case null: return null } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts index 58bbfe9454e..62801dcfe31 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts @@ -513,6 +513,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr return `A ${createReviewCopy.reviewLabel} already exists` case 'fork_head_unsupported': return 'Fork head unsupported' + case 'base_not_on_remote': + return 'Base branch is not on the remote' case null: case undefined: return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready' diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts index ce4849a89f9..6ee7bce037f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-create-pr-intent-action.ts @@ -180,6 +180,7 @@ export function resolveDisabledCreatePrHeaderAction( case 'existing_review': case 'fork_head_unsupported': case 'unsupported_provider': + case 'base_not_on_remote': case null: title = translate( 'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581', @@ -311,11 +312,7 @@ export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): Primar return createPrIntent } - // Why: blocked notices are only for states the preparation intent cannot - // safely resolve, such as auth/default-branch/unsafe sync blockers. - if (canClickBlockedCreateReviewReason(inputs.hostedReviewCreation?.blockedReason)) { - return resolveDisabledCreatePrHeaderAction(inputs) - } - + // Why: any remaining blocked state (including create-time-only base_not_on_remote) + // falls back to the disabled header action with its explanatory title. return resolveDisabledCreatePrHeaderAction(inputs) } diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index e23db872e1b..d09fc41cc32 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -155,14 +155,36 @@ describe('useCreatePullRequestDialogFields', () => { } }) - it('prefers the selected current base ref over stale eligibility defaults', async () => { + it('prefers the remote-validated eligibility default over a stacked local-only base', async () => { + // Why: for a stacked worktree the current base is the local-only parent + // branch, which the main process resolves to the repo default. The seeded + // field must follow the remote-validated eligibility default, not the parent. const harness = renderDialogFields({ eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }), - currentBaseRef: 'refs/remotes/origin/release' + currentBaseRef: 'stacked-parent' }) try { await harness.rerender({ eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }), + currentBaseRef: 'stacked-parent' + }) + + expect(harness.current().base).toBe('main') + } finally { + harness.unmount() + } + }) + + it('falls back to the current base ref when eligibility supplies no default', async () => { + // Why: when the main process cannot resolve a default (e.g. origin/HEAD + // unset and no probes match), keep the current base rather than blanking it. + const harness = renderDialogFields({ + eligibility: createEligibility({ defaultBaseRef: null }), + currentBaseRef: 'refs/remotes/origin/release' + }) + try { + await harness.rerender({ + eligibility: createEligibility({ defaultBaseRef: null }), currentBaseRef: 'refs/remotes/origin/release' }) @@ -212,7 +234,7 @@ describe('useCreatePullRequestDialogFields', () => { const seedRevisions = { ...harness.current().fieldRevisions } await harness.rerender({ - eligibility: createEligibility(), + eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/release' }), currentBaseRef: 'refs/remotes/origin/release' }) expect(harness.current().base).toBe('release') diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index a480689334e..08a3b311de6 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -91,7 +91,13 @@ function resolveCreateReviewDefaultBaseRef({ currentBaseRef?: string | null eligibilityDefaultBaseRef?: string | null }): string { - return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '') + // Why: prefer the remote-validated main-process default over the worktree's + // local parent base. For a stacked worktree whose parent is local-only, + // `currentBaseRef` is that unpushable parent; the eligibility default has + // already fallen back to a ref the remote can resolve. Fall back to + // `currentBaseRef` only when eligibility supplied no default. Manual + // `setUserBase` still wins via the base-resync suppression. + return stripBaseRef(eligibilityDefaultBaseRef?.trim() || currentBaseRef?.trim() || '') } export function normalizeCreateReviewBaseSearchResults( diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 6a4c8b197cf..957e0a9bb1e 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -1832,14 +1832,29 @@ describe('agent completion coordinator', () => { const turn = { prompt: 'fix the bug', agentType: 'codex' as const } coordinator.observeHookStatus({ state: 'working', ...turn }) - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'exec_command', + toolInput: 'ls' + }) // First pause auto-resolves before the window elapses. - coordinator.observeHookStatus({ state: 'working', ...turn, toolName: 'exec_command', toolInput: 'ls' }) + coordinator.observeHookStatus({ + state: 'working', + ...turn, + toolName: 'exec_command', + toolInput: 'ls' + }) vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) expect(dispatchAttention).not.toHaveBeenCalled() // A later, genuinely-distinct pause must re-arm the debounce and fire. - coordinator.observeHookStatus({ state: 'waiting', ...turn, toolName: 'apply_patch', toolInput: 'diff' }) + coordinator.observeHookStatus({ + state: 'waiting', + ...turn, + toolName: 'apply_patch', + toolInput: 'diff' + }) expect(dispatchAttention).not.toHaveBeenCalled() vi.advanceTimersByTime(CODEX_ATTENTION_QUIET_MS) expect(dispatchAttention).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 1801c78bace..228af0058eb 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -112,9 +112,8 @@ describe('agent hook completion notifications', () => { // Why: the Codex permission-pause tests share a working→pause→quiet-window // sequence; centralizing it keeps the debounce advance (issue #8387) in one spot. async function observeCodexPermissionPause(state: 'waiting' | 'blocked'): Promise { - const { observeAgentHookCompletionForNotification } = await import( - './agent-hook-completion-notifications' - ) + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') observeAgentHookCompletionForNotification({ paneKey, worktreeId: 'wt-1', diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 864baceba87..04094cf0774 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -104,6 +104,10 @@ export type HostedReviewCreationBlockedReason = | 'fork_head_unsupported' | 'unsupported_provider' | 'existing_review' + // Why: a stacked worktree's local-only parent base is unresolvable on the + // remote; blocked at create-time so the submit fails with actionable copy + // instead of the provider's opaque error. + | 'base_not_on_remote' | null export type HostedReviewCreationNextAction = From 81cc966ea3a23c41e72f98f8aa9793143f5fef49 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:12:08 +0000 Subject: [PATCH 44/54] release: v1.4.139-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1bf2bc74f30..e62fce386e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.139-rc.1", + "version": "1.4.139-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From b379610cbedd9cfe7750c5e9d2b0464334dc423e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:48:00 -0700 Subject: [PATCH 45/54] fix(browser): handle Cmd-click popups without adopted WebContents (#8659) Co-authored-by: Orca --- src/main/browser/popup-origin-bar-window.test.ts | 6 ++++++ src/main/browser/popup-origin-bar-window.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts index c0161dd94e8..3e816e74b07 100644 --- a/src/main/browser/popup-origin-bar-window.test.ts +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -53,6 +53,11 @@ const { fakeElectron } = vi.hoisted(() => { webContents: FakeWebContents setBounds = vi.fn() constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) { + // Why: Electron rejects explicit undefined instead of treating it as + // omitted, which the popup fallback path depends on. + if (Object.hasOwn(options, 'webContents') && options.webContents === undefined) { + throw new TypeError('options.webContents must be a WebContents') + } this.options = options this.webContents = options.webContents ?? createFakeWebContents() FakeWebContentsView.instances.push(this) @@ -186,6 +191,7 @@ describe('openPopupWithOriginBar', () => { it('loads the target itself only when no pre-created contents were provided', () => { const popup = openPopupWithOriginBar({}, 'https://example.com/login') + expect(lastViews().content.options).not.toHaveProperty('webContents') expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login') }) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts index 504c862a5b8..c9ac057c177 100644 --- a/src/main/browser/popup-origin-bar-window.ts +++ b/src/main/browser/popup-origin-bar-window.ts @@ -120,7 +120,9 @@ export function openPopupWithOriginBar( webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }) const contentView = new WebContentsView({ - webContents: options.webContents, + // Why: Electron rejects an explicitly undefined webContents; omitting it + // lets WebContentsView create contents for Cmd/Ctrl-click popups. + ...(options.webContents === undefined ? {} : { webContents: options.webContents }), webPreferences: options.webPreferences }) window.contentView.addChildView(contentView) From 5a3e4b44f85b74bee12f328961967551fa2b873b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:10:01 -0700 Subject: [PATCH 46/54] fix(lint): cover active/inactive in the terminal tab activity switch (#8660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WorktreeStatus union feeding activityDotState widened, and the switch-exhaustiveness gate now fails on main itself (verify does not run on pushes to main, so the break landed silently and blocks every PR's CI). Cover the two glyph-less states explicitly — matching the function's documented intent — and drop the now-unreachable default. --- src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 5c3b0c7dc24..72e8ed3f276 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -35,7 +35,8 @@ function activityDotState(status: TerminalTabActivityStatus): AgentDotState | nu return 'permission' case 'done': return 'done' - default: + case 'active': + case 'inactive': return null } } From 9875b5528a3d4164b1556e8b1daf457d8bf21b7a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:24:33 -0700 Subject: [PATCH 47/54] Improve native chat style (#8654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redesign native chat interactive UI to match assistant-turn styling - Restyle approval and question cards as bordered cards docked in the composer region instead of top-border strips, and widen the chat column (max-w-3xl → max-w-4xl) to match the rest of the app - Rework the question card into a numbered pick-list with an always- present free-text row and explicit Next/Skip/Send action, replacing the prior "Other…" toggle flow - Hide the composer while a question card is active since it supplies its own answer input, and hold the card open until the paced answer send settles instead of dismissing on click - Auto-repin the message list to the bottom during in-place streaming growth via a ResizeObserver on the content, not just the viewport - Widen question-answer pacing (800ms→1000ms step, 300ms→500ms buffer) for reliability on slower machines / higher-latency SSH sessions - Restyle tool-run/tool-line disclosure to a right-side hover chevron and add a formatted (pretty-printed) detail view for diff-less calls - Add "Skip" i18n string across all locales * Extend paste bridge and question-card fixes for native chat interactive - Route pane-level Paste to the question card's free-text input when the composer is unmounted, so Cmd/Ctrl+V keeps working while a question prompt is showing. - Fix a stale-timer bug where a new interactive prompt could inherit the previous card's dismiss/submitting state and swallow its first answer. - Only render tool-run detail when there's actually content to show, and mark selected question options with aria-pressed for accessibility. --- .../native-chat/NativeChatApprovalCard.tsx | 58 +++-- .../native-chat/NativeChatComposerField.tsx | 9 +- .../native-chat/NativeChatInteractiveCard.tsx | 53 +++- .../native-chat/NativeChatMessageList.tsx | 37 ++- .../native-chat/NativeChatQuestionCard.tsx | 243 +++++++++++------- .../native-chat/NativeChatToolRun.tsx | 83 ++++-- .../components/native-chat/NativeChatView.tsx | 71 ++--- .../native-chat-runtime-send.test.ts | 8 +- .../native-chat/native-chat-runtime-send.ts | 6 +- .../native-chat/native-chat-tool-summary.ts | 20 ++ .../use-native-chat-context-menu.tsx | 17 ++ .../use-native-chat-interactive-send.ts | 16 +- .../use-native-chat-paste-bridge.ts | 34 ++- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 18 files changed, 446 insertions(+), 224 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index cbf041f62e3..9b394ada907 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -19,35 +19,37 @@ export function NativeChatApprovalCard({ onChoose }: NativeChatApprovalCardProps): React.JSX.Element { return ( -
-
-
- -
-

{approval.title}

- {approval.detail ? ( -

- {approval.detail} -

- ) : null} +
+
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))}
-
-
- {approval.options.map((opt, i) => ( - - ))}
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d3149c8c4ca..5a7b2a90255 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -85,8 +85,9 @@ export function NativeChatComposerField({ }: NativeChatComposerFieldProps): React.JSX.Element { return (
-
-
+ {/* Extra bottom padding keeps the input box off the window rim. */} +
+
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( {imageAttachments.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 39bbba9a75e..947d5f65c3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' @@ -23,11 +23,19 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se export function NativeChatInteractiveCard({ paneKey, send, - canSend + canSend, + onShowingQuestionChange, + answerInputRef }: { paneKey: string send: NativeChatInteractiveSend canSend: boolean + /** Reports whether a question card is on screen so the view can replace the + * composer with it (the card's free-text row is the answer input). */ + onShowingQuestionChange?: (showing: boolean) => void + /** Forwarded to the question card's free-text row so pane-level Paste keeps + * a target while the composer is unmounted. */ + answerInputRef?: React.RefObject }): React.JSX.Element | null { const interactivePrompt = useAppStore( (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null @@ -43,15 +51,40 @@ export function NativeChatInteractiveCard({ ) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) + // A question answer is a paced multi-step write (body→Enter per question); keep + // the card up until it settles instead of dismissing on the click, so it doesn't + // vanish mid-send. `submitting` also gates a second submit racing the first. + const dismissTimerRef = useRef | null>(null) + const submittingRef = useRef(false) + const clearDismissTimer = (): void => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current) + dismissTimerRef.current = null + } + submittingRef.current = false + } + // Keyed on cardKey (and unmount): a new prompt can replace the current one + // before the settle timer fires, and a stale `submitting` gate would swallow + // the new card's first answer. + useEffect(() => clearDismissTimer, [cardKey]) // Forget the dismissal once the prompt clears so a fresh prompt can show. const present = card != null useEffect(() => { if (!present) { setDismissedKey(null) + clearDismissTimer() } }, [present]) + // Tell the view when a question card is up so it can hide the composer (this + // card supplies its own input). Reset on unmount so the composer comes back. + const showingQuestion = card?.kind === 'question' && canSend && cardKey !== dismissedKey + useEffect(() => { + onShowingQuestionChange?.(showingQuestion) + return () => onShowingQuestionChange?.(false) + }, [showingQuestion, onShowingQuestionChange]) + if (!card || !canSend || cardKey === dismissedKey) { return null } @@ -60,11 +93,23 @@ export function NativeChatInteractiveCard({ { - setDismissedKey(cardKey) - sendAnswer(text) + if (submittingRef.current) { + return + } + submittingRef.current = true + const settleMs = sendAnswer(text) + // Hold the card until the paced write finishes, then mark it answered + // (which hides it and restores the composer). + dismissTimerRef.current = setTimeout(() => { + setDismissedKey(cardKey) + submittingRef.current = false + dismissTimerRef.current = null + }, settleMs) }} onCancel={() => { + clearDismissTimer() setDismissedKey(cardKey) cancel() }} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 313845c2030..71aee362083 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -167,7 +167,9 @@ function MessageRow({ // transcript caught up.) return (
-
+ {/* User turns get a distinct muted fill (not the card/canvas color) so + the prompt reads apart from the assistant's body copy. */} +
{markdown ? ( <> @@ -213,7 +215,7 @@ function MessageRow({ ) : null} @@ -251,6 +253,7 @@ export function NativeChatMessageList({ failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) + const contentRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) const [showJump, setShowJump] = useState(false) @@ -312,6 +315,11 @@ export function NativeChatMessageList({ if (!container) { return } + // Detach synchronously (not just via the pending onScroll) so an in-place + // streaming growth can't re-pin to the bottom mid-flight and fight this + // deliberate scroll. The ref is what the resize observer reads. + stuckToBottomRef.current = false + setStuckToBottom(false) const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' }) }, []) @@ -334,17 +342,31 @@ export function NativeChatMessageList({ } }, [messages.length, isWorking, showTypingIndicator, scrollToBottom]) - // Keep the affordances in sync if the container resizes (e.g. composer mounts, - // viewport reflow) without a scroll event. + // Content growing without a message-count change (a streaming assistant turn + // extends its own message in place) never re-fires the layout effect above. + // Observe the container so those in-place growths still re-pin: stay glued to + // the bottom while stuck, otherwise just refresh the jump affordance. This is + // what removes most "Jump to latest" clicks during a live response. useEffect(() => { const el = scrollRef.current if (!el || typeof ResizeObserver === 'undefined') { return } - const observer = new ResizeObserver(handleScroll) + const observer = new ResizeObserver(() => { + if (stuckToBottomRef.current) { + scrollToBottom() + } else { + handleScroll() + } + }) + // Observe the growing content, not just the fixed-height viewport, so an + // in-place streaming growth is seen; also watch the viewport for reflows. observer.observe(el) + if (contentRef.current) { + observer.observe(contentRef.current) + } return () => observer.disconnect() - }, [handleScroll]) + }, [handleScroll, scrollToBottom]) return (
@@ -354,7 +376,8 @@ export function NativeChatMessageList({ className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4" >
void /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void + /** Exposes the free-text row so pane-level Paste can target it while the + * card replaces the composer. */ + answerInputRef?: RefObject } -// Synthetic option value for the "Other…" free-text row, kept out of the -// answer text and replaced by the typed value when selected. -const OTHER = '__other__' - /** - * Native renderer for an agent's AskUserQuestion prompt as a wizard: one - * question per step with tabs across the top (tap to jump, a check once - * answered), single- or multi-select option rows, an "Other…" row that reveals a - * free-text input, and a Next button that advances and becomes "Send answer" on - * the last step. Matches the desktop chat's neutral shadcn styling. + * Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list + * (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per + * option, and an always-present free-text row for a custom answer. Single-select + * commits on click; multi-select toggles and confirms via the trailing action. + * Multi-question prompts step through tabs across the top. Neutral shadcn tokens. */ export function NativeChatQuestionCard({ prompt, onAnswer, - onCancel + onCancel, + answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) - const toggle = (qi: number, label: string, multi: boolean): void => { - setSelections((prev) => { - const next = prev.map((s) => [...s]) - const cur = next[qi] ?? [] - if (multi) { - next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] - } else { - next[qi] = cur.includes(label) ? [] : [label] - } - return next - }) - } + const total = prompt.questions.length + const isLast = index === total - 1 + const q = prompt.questions[index]! const setOther = (qi: number, value: string): void => { setOtherText((prev) => { @@ -53,28 +44,19 @@ export function NativeChatQuestionCard({ }) } - // The resolved answer for a question: picked labels plus the typed "Other" - // value (which replaces the synthetic OTHER marker). - const answerFor = (qi: number): string => { - const picked = (selections[qi] ?? []).filter((l) => l !== OTHER) - const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' - return [...picked, other].filter((p) => p.length > 0).join(', ') + // The resolved answer for a question: picked labels plus any typed free-text. + const answerFor = (qi: number, sel = selections, oth = otherText): string => { + const picked = sel[qi] ?? [] + const other = (oth[qi] ?? '').trim() + return [...picked, ...(other ? [other] : [])].join(', ') } - const total = prompt.questions.length - const isLast = index === total - 1 - const currentAnswered = useMemo( - () => answerFor(index).length > 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selections, otherText, index] - ) + const currentAnswered = answerFor(index).length > 0 - const submit = (): void => { - // Build per-question label lists, substituting the typed Other value, then - // format to one line per answered question. + const submitAll = (sel: string[][], oth: string[]): void => { const resolved = prompt.questions.map((_, i) => { - const picked = (selections[i] ?? []).filter((l) => l !== OTHER) - const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : '' + const picked = sel[i] ?? [] + const other = (oth[i] ?? '').trim() return [...picked, ...(other ? [other] : [])] }) const text = formatAskAnswer(prompt, resolved) @@ -83,22 +65,60 @@ export function NativeChatQuestionCard({ } } - const advance = (): void => { + // Advance to the next question, or submit on the last one — always from an + // explicit snapshot so a just-committed single-select pick isn't lost to the + // async setState. + const advanceOrSubmit = (sel: string[][], oth: string[]): void => { if (isLast) { - submit() + submitAll(sel, oth) } else { setIndex((i) => Math.min(i + 1, total - 1)) } } - const q = prompt.questions[index]! - const otherSelected = (selections[index] ?? []).includes(OTHER) + // Selecting only highlights the row; submitting is an explicit step via the + // trailing Send/Next button. (Auto-submitting on the first click dismissed the + // card before the user saw any feedback, which read as "nothing happened".) + const pickOption = (label: string): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[index] ?? [] + if (q.multiSelect) { + next[index] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] + } else { + next[index] = cur.includes(label) ? [] : [label] + } + return next + }) + } + + // Trailing action (also fired by Enter). On any non-final question this just + // advances — "Next" when answered, "Skip" when not — so skipping one question + // never discards answers already given on the others. Only the final question + // submits; an explicit Skip click there with nothing answered anywhere + // dismisses, but a reflexive Enter in the empty field is a no-op so it can't + // throw away the whole prompt. + const confirm = (fromKeyboard = false): void => { + if (!isLast) { + advanceOrSubmit(selections, otherText) + return + } + const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0) + if (anyAnswered) { + submitAll(selections, otherText) + } else if (!fromKeyboard) { + onCancel() + } + } return ( -
-
+ // Part of the composer: docked in the bottom input region, matching the + // composer's width and padding, rendered as the "ask" dialog card directly + // above the text input. Its free-text row is the answer input. +
+
{total > 1 ? ( -
+
{prompt.questions.map((qq, i) => ( +
+ + {/* Scroll only kicks in on long option lists; the sleek scrollbar rides + the card's right edge instead of crowding the choices. */} +
+ {q.options.map((opt, i) => ( toggle(index, opt.label, q.multiSelect)} + onSelect={() => pickOption(opt.label)} /> ))} - toggle(index, OTHER, q.multiSelect)} - /> - {otherSelected ? ( -