From 41ce4fadd907632252b87b500e5d13737d9a1c20 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:29:23 -0700 Subject: [PATCH 01/12] Fix GitLab MR management menu in Checks sidebar (#16906) * fix: add GitLab MR management menu * fix: restore GitLab menu typecheck * fix stale GitLab review relink updates * fix review relink guard lifecycle * test local owner scope for GitLab relinks * fix(gitlab): honor linked MR during review lookup * fix: reuse hosted review cache after relink * fix: avoid duplicate GitLab detail refresh --- .../gitlab/client-mr-branch-lookup.test.ts | 116 +++++++++------ src/main/gitlab/merge-request-lookup.ts | 43 +++--- src/main/ipc/gitlab-repo-access.test.ts | 47 +++++++ src/main/ipc/gitlab-repo-access.ts | 13 ++ src/main/ipc/hosted-review.test.ts | 32 +++++ src/main/ipc/hosted-review.ts | 24 +++- src/preload/api/gitlab-api.ts | 2 + .../ChecksPanel.review-header.test.tsx | 20 +-- .../components/right-sidebar/ChecksPanel.tsx | 83 ++++++----- .../checks-panel/active-content-props.ts | 5 +- .../checks-panel/active-content.tsx | 19 +-- .../check-and-review-action-dependencies.ts | 3 + .../checks-panel/gitlab-review-client.ts | 2 + .../panel-content-rendering.test.tsx | 5 +- ...ks-panel-check-and-review-actions.test.tsx | 133 ++++++++++++++++++ ...-checks-panel-check-and-review-actions.tsx | 54 ++----- .../use-checks-panel-polling.test.tsx | 116 +++++++++++++++ .../checks-panel/use-checks-panel-polling.tsx | 34 +++-- .../use-checks-panel-review-link-actions.tsx | 130 +++++++++++++++++ .../sidebar/WorktreeMetaDialog.test.tsx | 32 ++++- .../components/sidebar/WorktreeMetaDialog.tsx | 105 +++++++------- .../sidebar/WorktreeReviewLinkField.tsx | 60 ++++++++ .../sidebar/use-worktree-meta-workspace.ts | 7 +- .../sidebar/worktree-meta-updates.test.ts | 49 ++++++- .../sidebar/worktree-meta-updates.ts | 53 +++++-- src/renderer/src/i18n/locales/en.json | 11 +- .../store/slices/hosted-review-cache-state.ts | 38 ++++- .../slices/hosted-review-card-refresh.ts | 2 + .../src/store/slices/hosted-review.ts | 18 +-- ...orktrees-linked-review-push-target.test.ts | 1 + .../worktrees-metadata-persistence.test.ts | 1 + .../metadata/hosted-review-link-mutation.ts | 7 + .../metadata/update-worktree-meta.ts | 15 +- .../web/preload-api/web-gitlab-api.test.ts | 31 ++++ .../src/web/preload-api/web-gitlab-api.ts | 2 +- src/shared/hosted-review.ts | 2 + 36 files changed, 1046 insertions(+), 269 deletions(-) create mode 100644 src/main/ipc/gitlab-repo-access.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeReviewLinkField.tsx create mode 100644 src/renderer/src/web/preload-api/web-gitlab-api.test.ts diff --git a/src/main/gitlab/client-mr-branch-lookup.test.ts b/src/main/gitlab/client-mr-branch-lookup.test.ts index 948e3510b03..b311882da3b 100644 --- a/src/main/gitlab/client-mr-branch-lookup.test.ts +++ b/src/main/gitlab/client-mr-branch-lookup.test.ts @@ -212,9 +212,9 @@ describe('gitlab client — MR operations', () => { await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() }) - it('falls back to a linked MR iid when the branch lookup misses', async () => { + it('resolves a linked MR by iid without querying the branch', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ iid: 9, title: 'Linked MR', @@ -226,15 +226,64 @@ describe('gitlab client — MR operations', () => { const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9) expect(mr?.number).toBe(9) expect(mr?.pipelineStatus).toBe('success') - expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith( - ['api', 'projects/g%2Fp/merge_requests/9'], + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/9?with_merge_status_recheck=true'], { cwd: '/repo' } ) }) - it('preserves merged state when falling back to a linked MR iid', async () => { + it('uses the explicitly linked MR when the branch still matches a different MR', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 2, + title: 'Replacement linked MR', + state: 'opened', + sha: 'head-2', + head_pipeline: { status: 'pending' } + }) + }) + + const mr = await getMergeRequestForBranch('/repo', 'qa/real-mr', 2) + + expect(mr).toMatchObject({ + number: 2, + title: 'Replacement linked MR', + pipelineStatus: 'pending' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], + { cwd: '/repo' } + ) + }) + + it('uses one exact lookup when the linked MR also matches the branch', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 2, + title: 'Already selected MR', + state: 'opened', + sha: 'head-2', + head_pipeline: { status: 'success' } + }) + }) + + await expect( + getMergeRequestForBranch('/repo', 'qa/replacement-mr', 2) + ).resolves.toMatchObject({ number: 2 }) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], + { cwd: '/repo' } + ) + }) + + it('preserves merged state when resolving a linked MR iid', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ iid: 10, title: 'Merged linked MR', @@ -353,57 +402,44 @@ describe('gitlab client — MR operations', () => { expect(mr?.state).toBe('closed') }) - it('discards a closed default-branch shadow and refetches the linked MR via the fallback (#9171)', async () => { + it('resolves a linked default-branch MR without consulting a branch shadow (#9171)', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - iid: 7, - title: 'Accidental MR from main', - state: 'closed', - sha: 'stale-main-oid', - head_pipeline: { status: 'success' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 42, - title: 'Linked MR', - state: 'merged', - pipeline: { status: 'success' } - }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 42, + title: 'Linked MR', + state: 'merged', + pipeline: { status: 'success' } }) + }) const mr = await getMergeRequestForBranch('/repo', 'main', 42) expect(mr).toMatchObject({ number: 42, state: 'merged' }) - expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith( - ['api', 'projects/g%2Fp/merge_requests/42'], + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/42?with_merge_status_recheck=true'], { cwd: '/repo' } ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) - it('keeps a non-open branch match on the default branch when it IS the linked MR', async () => { + it('keeps a linked non-open MR on the default branch', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - iid: 7, - title: 'Linked trunk MR', - state: 'merged', - sha: 'abc', - head_pipeline: { status: 'success' } - } - ]) + stdout: JSON.stringify({ + iid: 7, + title: 'Linked trunk MR', + state: 'merged', + sha: 'abc', + head_pipeline: { status: 'success' } + }) }) const mr = await getMergeRequestForBranch('/repo', 'main', 7) expect(mr).toMatchObject({ number: 7, state: 'merged' }) - // Exempted by linked-number match — no fallback refetch needed. - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() }) it('returns null for an empty / detached-HEAD branch arg', async () => { diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts index fa2909d3aff..96b6f7bad28 100644 --- a/src/main/gitlab/merge-request-lookup.ts +++ b/src/main/gitlab/merge-request-lookup.ts @@ -78,8 +78,8 @@ export async function getMergeRequest( } /** - * Find the merge request whose source branch matches the given name. - * Returns the most recently updated MR for the branch, or null when none exists. + * Find the explicitly linked merge request, or the newest MR whose source branch matches. + * Returns null when neither exists. */ export async function getMergeRequestForBranch( repoPath: string, @@ -103,6 +103,22 @@ export async function getMergeRequestForBranch( } await acquire() try { + if (typeof linkedMRIid === 'number') { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}?with_merge_status_recheck=true` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const raw = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + return mapMRInfo(raw, pipelineStatus) + } if (branchName) { const { stdout } = await glabExecFileAsync( [ @@ -125,12 +141,10 @@ export async function getMergeRequestForBranch( // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-iid fallback below. + // Why (#9171): discard a non-open implicit branch match on the repo default branch. const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ state: info.state, reviewNumber: info.number, - linkedReviewNumber: linkedMRIid, branchName, repoPath, connectionId, @@ -141,24 +155,7 @@ export async function getMergeRequestForBranch( } } } - if (typeof linkedMRIid !== 'number') { - return null - } - // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const raw = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - return mapMRInfo(raw, pipelineStatus) + return null } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/ipc/gitlab-repo-access.test.ts b/src/main/ipc/gitlab-repo-access.test.ts new file mode 100644 index 00000000000..cf1ed35e99b --- /dev/null +++ b/src/main/ipc/gitlab-repo-access.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { assertRegisteredRepo } from './gitlab-repo-access' + +const repoPath = '/workspace/repo' +const localRepo = { + id: 'repo-1', + path: repoPath, + displayName: 'local', + badgeColor: '#000', + addedAt: 0 +} +const sshRepo = { ...localRepo, displayName: 'ssh', connectionId: 'ssh-1' } + +function makeStore() { + return { + getRepo: vi.fn(() => localRepo), + getRepos: vi.fn(() => [localRepo, sshRepo]) + } +} + +describe('GitLab repo owner selection', () => { + it('selects the exact owner when ids and paths collide', () => { + expect( + assertRegisteredRepo( + { + repoPath, + repoId: 'repo-1', + repoOwnerExecutionHostId: 'ssh:ssh-1' + }, + makeStore() as never + ) + ).toBe(sshRepo) + }) + + it('fails closed when the explicit owner is absent', () => { + expect(() => + assertRegisteredRepo( + { + repoPath, + repoId: 'repo-1', + repoOwnerExecutionHostId: 'runtime:missing' + }, + makeStore() as never + ) + ).toThrow('Access denied: unknown repository path') + }) +}) diff --git a/src/main/ipc/gitlab-repo-access.ts b/src/main/ipc/gitlab-repo-access.ts index 5bf70e7ff2b..539ae2168fa 100644 --- a/src/main/ipc/gitlab-repo-access.ts +++ b/src/main/ipc/gitlab-repo-access.ts @@ -11,12 +11,25 @@ export type GitLabRepoSelectorArgs = { repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null + repoOwnerExecutionHostId?: string } function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined { const sourceRepoId = args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null const repoId = args.repoId?.trim() || sourceRepoId || null + if (args.repoOwnerExecutionHostId) { + const resolvedRepoPath = resolve(args.repoPath) + const matches = store + .getRepos() + .filter( + (repo) => + (!repoId || repo.id === repoId) && + resolve(repo.path) === resolvedRepoPath && + getRepoExecutionHostId(repo) === args.repoOwnerExecutionHostId + ) + return matches.length === 1 ? matches[0] : undefined + } if (repoId) { const repo = store.getRepo(repoId) if (repo) { diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index de912d740a3..c22e9bbfe26 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -314,6 +314,38 @@ describe('registerHostedReviewHandlers', () => { ) }) + it('uses the explicit owner when duplicate repos share an id and path', async () => { + const localRepo = { ...repo, connectionId: undefined } + store.getRepos.mockReturnValue([localRepo, repo]) + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + repoOwnerExecutionHostId: 'ssh:ssh-1', + branch: 'feature/owner' + }) + + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', branch: 'feature/owner' }) + ) + }) + + it('fails closed when an explicit repo owner is missing', async () => { + registerHostedReviewHandlers(store as never, stats as never) + + await expect( + handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + repoOwnerExecutionHostId: 'runtime:missing', + branch: 'feature/owner' + }) + ).rejects.toThrow('Access denied: unknown or ambiguous repository owner') + expect(getHostedReviewForBranchMock).not.toHaveBeenCalled() + }) + it('passes SSH connectionId through create eligibility instead of blocking the worktree', async () => { getHostedReviewCreationEligibilityMock.mockResolvedValueOnce({ provider: 'github', diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 5c54f26175f..8bd9b7cce91 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -19,6 +19,7 @@ import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache import { listRepoWorktrees } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' +import { getRepoExecutionHostId } from '../../shared/execution-host' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -36,6 +37,27 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } +function assertRegisteredRepoForBranch(args: HostedReviewForBranchArgs, store: Store): Repo { + if (!args.repoOwnerExecutionHostId) { + return assertRegisteredRepo(args.repoPath, store, args.repoId) + } + const matches = store.getRepos().filter((candidate) => { + const samePath = candidate.connectionId + ? normalizeRemoteHostedReviewPath(candidate.path) === + normalizeRemoteHostedReviewPath(args.repoPath) + : resolve(candidate.path) === resolve(args.repoPath) + return ( + candidate.id === args.repoId && + samePath && + getRepoExecutionHostId(candidate) === args.repoOwnerExecutionHostId + ) + }) + if (matches.length !== 1) { + throw new Error('Access denied: unknown or ambiguous repository owner') + } + return matches[0] +} + async function resolveHostedReviewWorktreePath( repo: Repo, store: Store, @@ -80,7 +102,7 @@ function normalizeRemoteHostedReviewPath(remotePath: string): string { export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { - const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const repo = assertRegisteredRepoForBranch(args, store) const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const review = await getHostedReviewForBranch({ repoPath: repo.path, diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts index dd3f0fdc689..3f034573deb 100644 --- a/src/preload/api/gitlab-api.ts +++ b/src/preload/api/gitlab-api.ts @@ -27,6 +27,8 @@ export type GitLabRepoSelectorArgs = { repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null + /** Desktop IPC-only owner guard; web adapters remove it before runtime RPC. */ + repoOwnerExecutionHostId?: string } // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx index c9d535b2a05..72a73046288 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx @@ -29,11 +29,11 @@ afterEach(() => { }) function renderHeader({ - canUnlinkPullRequest = true, + canUnlinkReview = true, provider = 'github', modifierHintDestination = 'system-browser' }: { - canUnlinkPullRequest?: boolean + canUnlinkReview?: boolean provider?: 'github' | 'gitlab' modifierHintDestination?: ChecksPanelHostedReviewModifierDestination } = {}): string { @@ -53,12 +53,12 @@ function renderHeader({ mergeable: 'UNKNOWN' }} isRefreshing={false} - canUnlinkPullRequest={canUnlinkPullRequest} + canUnlinkReview={canUnlinkReview} modifierHintDestination={modifierHintDestination} onRefresh={vi.fn()} onOpenReview={vi.fn()} - onUnlinkPullRequest={vi.fn()} - onLinkAnotherPullRequest={vi.fn()} + onUnlinkReview={vi.fn()} + onLinkAnotherReview={vi.fn()} /> ) } @@ -109,19 +109,19 @@ describe('ChecksPanelReviewHeader', () => { }) it('disables unlinking when the displayed PR is not manually linked', () => { - const markup = renderHeader({ canUnlinkPullRequest: false }) + const markup = renderHeader({ canUnlinkReview: false }) expect(markup).toContain('data-disabled="true"') expect(markup).toContain('unlink PR') }) - it('shows GitLab MR identity without GitHub-only link management actions', () => { + it('shows GitLab MR identity with provider-appropriate link management actions', () => { const markup = renderHeader({ provider: 'gitlab' }) expect(markup).toContain('Open on GitLab') expect(markup).toContain('!31') - expect(markup).not.toContain('More PR actions') - expect(markup).not.toContain('unlink PR') - expect(markup).not.toContain('Link another PR') + expect(markup).toContain('More MR actions') + expect(markup).toContain('Unlink MR') + expect(markup).toContain('Link another MR') }) }) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 78a3f9f5628..b475e2a894f 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -41,28 +41,31 @@ import { ChecksPanelActiveContent } from './checks-panel/active-content' type ChecksPanelReviewHeaderProps = { review: ChecksPanelReview isRefreshing: boolean - canUnlinkPullRequest: boolean + canUnlinkReview: boolean modifierHintDestination: ChecksPanelHostedReviewModifierDestination onRefresh: () => void onOpenReview: (event: React.MouseEvent) => void - onUnlinkPullRequest: () => void - onLinkAnotherPullRequest: () => void + onUnlinkReview: () => void + onLinkAnotherReview: () => void } export function ChecksPanelReviewHeader({ review, isRefreshing, - canUnlinkPullRequest, + canUnlinkReview, modifierHintDestination, onRefresh, onOpenReview, - onUnlinkPullRequest, - onLinkAnotherPullRequest + onUnlinkReview, + onLinkAnotherReview }: ChecksPanelReviewHeaderProps): React.JSX.Element { const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}` const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub' - const showPullRequestMenu = review.provider === 'github' + const moreActionsLabel = + review.provider === 'gitlab' + ? translate('auto.components.right.sidebar.ChecksPanel.gitlabMoreActions', 'More MR actions') + : translate('auto.components.right.sidebar.ChecksPanel.653c105ecc', 'More PR actions') const openTitle = translate( 'auto.components.right.sidebar.ChecksPanel.5c88c6db07', 'Open on {{value0}}', @@ -104,38 +107,40 @@ export function ChecksPanelReviewHeader({ > - {showPullRequestMenu && ( - - - - - - - - {translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} - - - - {translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')} - - - - )} + + + + + + + + {review.provider === 'gitlab' + ? translate('auto.components.right.sidebar.ChecksPanel.gitlabUnlink', 'Unlink MR') + : translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} + + + + {review.provider === 'gitlab' + ? translate( + 'auto.components.right.sidebar.ChecksPanel.gitlabLinkAnother', + 'Link another MR' + ) + : translate( + 'auto.components.right.sidebar.ChecksPanel.07871c0589', + 'Link another PR' + )} + + + ) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts index 3952accd4ac..b1695c8ca8a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts @@ -46,6 +46,7 @@ export type ChecksPanelActiveContentModel = Pick< | 'activeConflictReview' | 'activeGitLabReview' | 'activeReview' + | 'linkedGitLabMR' | 'linkedPR' | 'pr' | 'prRefreshState' @@ -85,10 +86,10 @@ export type ChecksPanelActiveContentModel = Pick< Pick< ChecksPanelCheckAndReviewActionsState, | 'handleFixChecksWithAI' - | 'handleLinkAnotherPullRequest' + | 'handleLinkAnotherReview' | 'handleOpenPR' | 'handleOpenStackPR' - | 'handleUnlinkPullRequest' + | 'handleUnlinkReview' > & Pick & Pick & diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 3b26c0130d5..237e76b239b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -23,12 +23,12 @@ import type { ChecksPanelActiveContentModel } from './active-content-props' type ReviewHeaderComponentProps = { review: ChecksPanelReview isRefreshing: boolean - canUnlinkPullRequest: boolean + canUnlinkReview: boolean modifierHintDestination: ChecksPanelHostedReviewModifierDestination onRefresh: () => void onOpenReview: (event: React.MouseEvent) => void - onUnlinkPullRequest: () => void - onLinkAnotherPullRequest: () => void + onUnlinkReview: () => void + onLinkAnotherReview: () => void } export function ChecksPanelActiveContent({ @@ -69,7 +69,7 @@ export function ChecksPanelActiveContent({ handleFixChecksWithAI, handleLaunchAborted, handleLaunchAccepted, - handleLinkAnotherPullRequest, + handleLinkAnotherReview, handleLoadCheckDetails, handleOpenPR, handleOpenStackPR, @@ -82,10 +82,11 @@ export function ChecksPanelActiveContent({ handleSetReaction, handleStartEdit, handleTitleKeyDown, - handleUnlinkPullRequest, + handleUnlinkReview, isFixingChecksWithAI, isRefreshing, isResolvingConflictsWithAI, + linkedGitLabMR, linkedPR, pendingCommentResolutionRef, pr, @@ -131,12 +132,14 @@ export function ChecksPanelActiveContent({ void handleRefresh()} onOpenReview={handleOpenPR} - onUnlinkPullRequest={handleUnlinkPullRequest} - onLinkAnotherPullRequest={handleLinkAnotherPullRequest} + onUnlinkReview={handleUnlinkReview} + onLinkAnotherReview={handleLinkAnotherReview} /> {detachedHeadDisplay && } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts b/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts index 522823f28b8..b0a6e91bbe3 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts @@ -28,10 +28,13 @@ export type ChecksPanelCheckAndReviewActionsInput = Pick< | 'fetchPRForBranch' | 'gitLabProjectRefRef' | 'isFixingChecksWithAI' + | 'localExecutionScope' | 'openModal' | 'panelContextKey' | 'panelContextKeyRef' | 'repo' + | 'repoConnectionId' + | 'runtimeEnvironmentId' | 'settings' | 'setChecks' | 'setChecksLoading' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts index 58b74e10fa8..6c98eff79d6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts @@ -27,6 +27,7 @@ export async function fetchGitLabMRDetailsForChecks(args: { repoId?: string settings: Parameters[0] iid: number + repoOwnerExecutionHostId?: string }): Promise { const target = getActiveRuntimeTarget(args.settings) if (target.kind === 'environment') { @@ -44,6 +45,7 @@ export async function fetchGitLabMRDetailsForChecks(args: { return (await window.api.gl.workItemDetails({ repoPath: args.repoPath, repoId: args.repoId, + repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, iid: args.iid, type: 'mr' })) as GitLabWorkItemDetails | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx index 63d494edfba..67d601120d3 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx @@ -78,7 +78,7 @@ describe('checks panel concrete content', () => { handleFixChecksWithAI: vi.fn(), handleLaunchAborted: vi.fn(), handleLaunchAccepted: vi.fn(), - handleLinkAnotherPullRequest: vi.fn(), + handleLinkAnotherReview: vi.fn(), handleLoadCheckDetails: vi.fn(), handleOpenPR: vi.fn(), handleRefresh: vi.fn(), @@ -91,11 +91,12 @@ describe('checks panel concrete content', () => { handleSetReaction: vi.fn(), handleStartEdit: vi.fn(), handleTitleKeyDown: vi.fn(), - handleUnlinkPullRequest: vi.fn(), + handleUnlinkReview: vi.fn(), isFixingChecksWithAI: false, isRefreshing: false, isResolvingConflictsWithAI: false, linkedPR: null, + linkedGitLabMR: null, pendingCommentResolutionRef: { current: null }, pr: null, prRefreshState: undefined, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx new file mode 100644 index 00000000000..3990d47ea3a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useChecksPanelCheckAndReviewActions } from './use-checks-panel-check-and-review-actions' + +type Input = Parameters[0] + +afterEach(cleanup) + +function makeInput(overrides: Partial = {}): Input { + const worktree = { + id: 'repo-1::/workspace/repo', + repoId: 'repo-1', + path: '/workspace/repo', + branch: 'refs/heads/feature/mr', + hostId: 'ssh:ssh-1', + displayName: 'MR workspace', + linkedGitLabMR: 42, + linkedPR: null, + comment: '' + } + return { + activeReview: { + provider: 'gitlab', + number: 42, + title: 'GitLab review', + state: 'open', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42', + status: 'success', + updatedAt: null, + mergeable: 'UNKNOWN' + }, + activeWorktree: worktree as Input['activeWorktree'], + activeWorktreeId: worktree.id, + asyncResultKeyRef: { current: '' }, + branch: 'feature/mr', + checks: [], + fetchHostedReviewForBranch: vi.fn(), + fetchPRCheckDetails: vi.fn(), + fetchPRChecks: vi.fn(), + fetchPRComments: vi.fn(), + fetchPRForBranch: vi.fn(), + gitLabProjectRefRef: { current: null }, + isCurrentAsyncResult: vi.fn(() => true), + isFixingChecksWithAI: false, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGiteaPR: null, + linkedGitLabMR: 42, + linkedPR: null, + localExecutionScope: null, + openModal: vi.fn(), + panelContextKey: 'context', + panelContextKeyRef: { current: 'context' }, + pr: null, + prCacheKey: 'pr-cache', + repo: { + id: 'repo-1', + path: '/workspace/repo', + connectionId: 'ssh-1' + } as NonNullable, + repoConnectionId: 'ssh-1', + runtimeEnvironmentId: null, + settings: null, + setChecks: vi.fn(), + setChecksLoading: vi.fn(), + setComments: vi.fn(), + setCommentsLoading: vi.fn(), + setIsFixingChecksWithAI: vi.fn(), + sourceControlAiActionsVisible: false, + stateRequestKey: 'state', + updateWorktreeMeta: vi.fn(), + ...overrides + } as Input +} + +describe('useChecksPanelCheckAndReviewActions GitLab links', () => { + it('unlinks the displayed MR through its provider slot', () => { + const input = makeInput() + const { result } = renderHook(() => useChecksPanelCheckAndReviewActions(input)) + + act(() => result.current.handleUnlinkReview()) + + expect(input.updateWorktreeMeta).toHaveBeenCalledWith( + input.activeWorktreeId, + { linkedGitLabMR: null }, + { executionHostId: 'ssh:ssh-1' } + ) + }) + + it('refreshes a replacement MR on the captured owner and leaves details to the poller', async () => { + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue({ + provider: 'gitlab', + number: 43, + title: 'Replacement', + state: 'open', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/43', + status: 'success', + updatedAt: null, + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + const openModal = vi.fn() + const input = makeInput({ fetchHostedReviewForBranch, openModal }) + const hook = renderHook(({ model }) => useChecksPanelCheckAndReviewActions(model), { + initialProps: { model: input } + }) + + act(() => hook.result.current.handleLinkAnotherReview()) + const modal = openModal.mock.calls[0]?.[1] + expect(modal.suppressHostedReviewRefresh).toBe(true) + hook.rerender({ + model: { + ...input, + activeWorktree: { ...input.activeWorktree, linkedGitLabMR: 43 }, + linkedGitLabMR: 43, + panelContextKey: 'context::gitlab::43' + } as Input + }) + await act(async () => modal.afterSave({ updates: { linkedGitLabMR: 43 } })) + + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/mr', + expect.objectContaining({ + linkedGitLabMR: 43, + repoOwnerExecutionHostId: 'ssh:ssh-1' + }) + ) + expect(fetchHostedReviewForBranch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index 1a7a34fa0fc..5838e9c8781 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -16,6 +16,7 @@ import { translate } from '@/i18n/i18n' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../../shared/github/check-types' import type { GitHubPRStackMapNavigationModifiers } from '../GitHubPRStackMap' import type { ChecksPanelCheckAndReviewActionsInput } from './check-and-review-action-dependencies' +import { useChecksPanelReviewLinkActions } from './use-checks-panel-review-link-actions' function hasGitHubCheckHandle(check: PRCheckDetail): boolean { return Boolean(check.checkRunId || check.workflowRunId || check.url) @@ -24,7 +25,6 @@ function hasGitHubCheckHandle(check: PRCheckDetail): boolean { export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndReviewActionsInput) { const { activeReview, - activeWorktree, activeWorktreeId, asyncResultKeyRef, branch, @@ -41,8 +41,6 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe linkedBitbucketPR, linkedGiteaPR, linkedGitLabMR, - linkedPR, - openModal, panelContextKey, panelContextKeyRef, pr, @@ -55,8 +53,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe setCommentsLoading, setIsFixingChecksWithAI, sourceControlAiActionsVisible, - stateRequestKey, - updateWorktreeMeta + stateRequestKey } = model const handleFixChecksWithAI = useCallback(async (): Promise => { if ( @@ -348,52 +345,17 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe [activeWorktreeId] ) - const handleUnlinkPullRequest = useCallback(() => { - if ( - !activeWorktreeId || - !activeWorktree || - activeReview?.provider !== 'github' || - linkedPR === null - ) { - return - } - void updateWorktreeMeta( - activeWorktreeId, - { linkedPR: null }, - { executionHostId: activeWorktree.hostId } - ) - }, [activeReview?.provider, activeWorktree, activeWorktreeId, linkedPR, updateWorktreeMeta]) - - const handleLinkAnotherPullRequest = useCallback(() => { - if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') { - return - } - openModal('edit-meta', { - worktreeId: activeWorktreeId, - // Why: the same workspace ID can exist under two hosts. Naming the owner - // keeps the dialog on this workspace instead of the ambiguous lookup. - repoId: activeWorktree.repoId, - executionHostId: activeWorktree.hostId, - currentDisplayName: activeWorktree.displayName, - currentIssue: activeWorktree.linkedIssue, - currentPR: activeWorktree.linkedPR ?? activeReview.number, - currentComment: activeWorktree.comment, - focus: 'pr', - afterSave: ({ updates }: { updates?: { linkedPR?: unknown } }) => { - const nextLinkedPR = updates?.linkedPR - if (typeof nextLinkedPR === 'number') { - void refreshLinkedGitHubPullRequest(nextLinkedPR) - } - } - }) - }, [activeReview, activeWorktree, activeWorktreeId, openModal, refreshLinkedGitHubPullRequest]) + const { handleUnlinkReview, handleLinkAnotherReview } = useChecksPanelReviewLinkActions( + model, + refreshLinkedGitHubPullRequest + ) return { handleFixChecksWithAI, refreshLinkedGitHubPullRequest, handleOpenPR, handleOpenStackPR, - handleUnlinkPullRequest, - handleLinkAnotherPullRequest + handleUnlinkReview, + handleLinkAnotherReview } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx index 5182241b42f..9d177efa92b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type * as GitLabReviewClient from './gitlab-review-client' @@ -36,6 +37,7 @@ function createModel(overrides: Partial = {}): PollingInput { const fetchPRChecks = vi.fn<() => Promise>().mockResolvedValue([]) return { activeGitLabReview: null, + activeWorktree: null, asyncResultKeyRef: { current: 'cache::main::42' }, branch: 'main', fetchPRChecks, @@ -125,4 +127,118 @@ describe('useChecksPanelPolling live behavior', () => { expect(gitlab.fetchDetails).toHaveBeenCalledOnce() expect(model.fetchPRChecks).not.toHaveBeenCalled() }) + + it('uses an explicit owner and missing head override for a replacement MR', async () => { + const ownerSettings = { + activeRuntimeEnvironmentId: 'owner-runtime' + } as PollingInput['settings'] + const model = createModel({ + activeGitLabReview: { + provider: 'gitlab', + number: 17, + headSha: 'old-head' + } as NonNullable, + activeWorktree: makeWorktree({ + id: 'worktree-1', + repoId: 'repo-1', + hostId: 'runtime:owner-runtime' + }), + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as PollingInput['settings'] + }) + const { result } = renderHook(() => useChecksPanelPolling(model)) + + await act(async () => + result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + headShaOverride: null, + commitAsCurrent: true, + settingsOverride: ownerSettings + }) + ) + + expect(gitlab.fetchDetails).toHaveBeenCalledWith( + expect.objectContaining({ + iid: 18, + settings: ownerSettings, + repoOwnerExecutionHostId: 'runtime:owner-runtime' + }) + ) + expect(model.asyncResultKeyRef.current).toContain('::18::none') + expect(model.asyncResultKeyRef.current).not.toContain('old-head') + }) + + it('drops replacement MR details when the relink scope changes in flight', async () => { + let resolveDetails!: (value: { + item: { projectRef: null } + pipelineJobs: PRCheckDetail[] + comments: [] + }) => void + gitlab.fetchDetails.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetails = resolve + }) + ) + let requestCurrent = true + const model = createModel() + const { result } = renderHook(() => useChecksPanelPolling(model)) + + const request = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true, + isRequestCurrent: () => requestCurrent + }) + await act(() => Promise.resolve()) + requestCurrent = false + resolveDetails({ + item: { projectRef: null }, + pipelineJobs: [], + comments: [] + }) + await act(async () => request) + + expect(model.setChecks).not.toHaveBeenCalled() + expect(model.setComments).not.toHaveBeenCalled() + expect(model.setChecksLoading).toHaveBeenLastCalledWith(false) + expect(model.setCommentsLoading).toHaveBeenLastCalledWith(false) + }) + + it('keeps loading owned by the newest replacement MR details request', async () => { + const detailsResolvers: ((value: { + item: { projectRef: null } + pipelineJobs: [] + comments: [] + }) => void)[] = [] + gitlab.fetchDetails.mockImplementation( + () => + new Promise((resolve) => { + detailsResolvers.push(resolve) + }) + ) + let firstRequestCurrent = true + const model = createModel() + const { result } = renderHook(() => useChecksPanelPolling(model)) + + const firstRequest = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true, + isRequestCurrent: () => firstRequestCurrent + }) + await act(() => Promise.resolve()) + firstRequestCurrent = false + const secondRequest = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true + }) + await act(() => Promise.resolve()) + + detailsResolvers[0]?.({ item: { projectRef: null }, pipelineJobs: [], comments: [] }) + await act(async () => firstRequest) + expect(model.setChecksLoading).not.toHaveBeenCalledWith(false) + expect(model.setCommentsLoading).not.toHaveBeenCalledWith(false) + + detailsResolvers[1]?.({ item: { projectRef: null }, pipelineJobs: [], comments: [] }) + await act(async () => secondRequest) + expect(model.setChecksLoading).toHaveBeenLastCalledWith(false) + expect(model.setCommentsLoading).toHaveBeenLastCalledWith(false) + }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx index 25ac79fc5e0..510a60cc025 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' import { gitLabPipelineJobsToPRChecks } from '../../../../../shared/gitlab-pipeline-checks' import { @@ -17,6 +17,7 @@ type ChecksPanelPollingInput = Pick< Pick< ChecksPanelControllerState, | 'asyncResultKeyRef' + | 'activeWorktree' | 'branch' | 'fetchPRChecks' | 'isPanelVisible' @@ -34,6 +35,7 @@ type ChecksPanelPollingInput = Pick< export function useChecksPanelPolling(model: ChecksPanelPollingInput) { const { + activeWorktree, activeGitLabReview, asyncResultKeyRef, branch, @@ -54,6 +56,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { setCommentsLoading, gitLabProjectRefRef } = model + const gitLabDetailsLoadingGenerationRef = useRef(0) // Fetch checks via cached store method const fetchChecks = useCallback( async ({ @@ -136,14 +139,19 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { async ({ mrNumberOverride, headShaOverride, - commitAsCurrent = false + commitAsCurrent = false, + settingsOverride, + isRequestCurrent }: { mrNumberOverride?: number | null headShaOverride?: string | null commitAsCurrent?: boolean + settingsOverride?: ChecksPanelControllerState['settings'] + isRequestCurrent?: () => boolean } = {}) => { const targetMRNumber = mrNumberOverride ?? activeGitLabReview?.number ?? null - const targetHeadSha = headShaOverride ?? activeGitLabReview?.headSha ?? null + const targetHeadSha = + headShaOverride === undefined ? (activeGitLabReview?.headSha ?? null) : headShaOverride if (!repo || !targetMRNumber) { return } @@ -154,19 +162,25 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { targetMRNumber, targetHeadSha ) + if (isRequestCurrent?.() === false) { + return + } if (commitAsCurrent) { asyncResultKeyRef.current = requestKey } + const loadingGeneration = gitLabDetailsLoadingGenerationRef.current + 1 + gitLabDetailsLoadingGenerationRef.current = loadingGeneration setChecksLoading(true) setCommentsLoading(true) try { const details = await fetchGitLabMRDetailsForChecks({ repoPath: repo.path, repoId: repo.id, - settings, - iid: targetMRNumber + settings: settingsOverride ?? settings, + iid: targetMRNumber, + repoOwnerExecutionHostId: activeWorktree?.hostId }) - if (!isCurrentAsyncResult(requestKey)) { + if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) { return } gitLabProjectRefRef.current = details?.item.projectRef ?? null @@ -180,14 +194,17 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { : 30_000 prevChecksRef.current = signature } catch (err) { - if (!isCurrentAsyncResult(requestKey)) { + if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) { return } console.warn('Failed to fetch GitLab MR checks:', err) setChecks([]) setComments([]) } finally { - if (isCurrentAsyncResult(requestKey)) { + if ( + gitLabDetailsLoadingGenerationRef.current === loadingGeneration && + isCurrentAsyncResult(requestKey) + ) { setChecksLoading(false) setCommentsLoading(false) } @@ -196,6 +213,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { [ activeGitLabReview?.headSha, activeGitLabReview?.number, + activeWorktree?.hostId, branch, hostedReviewCacheKey, isCurrentAsyncResult, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx new file mode 100644 index 00000000000..6a7645667ec --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx @@ -0,0 +1,130 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { ChecksPanelCheckAndReviewActionsInput } from './check-and-review-action-dependencies' + +type RefreshLinkedGitHubPullRequest = (linkedPRNumber: number) => Promise + +export function useChecksPanelReviewLinkActions( + model: ChecksPanelCheckAndReviewActionsInput, + refreshLinkedGitHubPullRequest: RefreshLinkedGitHubPullRequest +) { + const { + activeReview, + activeWorktree, + activeWorktreeId, + branch, + fetchHostedReviewForBranch, + linkedGitLabMR, + linkedPR, + localExecutionScope, + openModal, + repo, + repoConnectionId, + runtimeEnvironmentId, + updateWorktreeMeta + } = model + const reviewLinkScopeKey = JSON.stringify([ + repo?.id ?? null, + repo?.path ?? null, + activeWorktree?.id ?? null, + activeWorktree?.path ?? null, + branch, + activeWorktree?.hostId ?? null, + repo?.executionHostId ?? null, + repoConnectionId, + runtimeEnvironmentId, + localExecutionScope + ]) + const reviewLinkScopeKeyRef = useRef(reviewLinkScopeKey) + const reviewLinkActionGenerationRef = useRef(0) + useLayoutEffect(() => { + reviewLinkScopeKeyRef.current = reviewLinkScopeKey + }, [reviewLinkScopeKey]) + + const handleUnlinkReview = useCallback(() => { + if (!activeWorktreeId || !activeWorktree || !activeReview) { + return + } + const updates = + activeReview.provider === 'gitlab' + ? linkedGitLabMR === null + ? null + : { linkedGitLabMR: null } + : linkedPR === null + ? null + : { linkedPR: null } + if (!updates) { + return + } + reviewLinkActionGenerationRef.current += 1 + void updateWorktreeMeta(activeWorktreeId, updates, { executionHostId: activeWorktree.hostId }) + }, [activeReview, activeWorktree, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta]) + + const handleLinkAnotherReview = useCallback(() => { + if (!activeWorktreeId || !activeWorktree || !activeReview || !repo || !branch) { + return + } + const provider = activeReview.provider + const openedScopeKey = reviewLinkScopeKey + openModal('edit-meta', { + worktreeId: activeWorktreeId, + // Why: the same workspace ID can exist under two hosts, so pin the dialog to its owner. + repoId: activeWorktree.repoId, + executionHostId: activeWorktree.hostId, + currentDisplayName: activeWorktree.displayName, + currentIssue: activeWorktree.linkedIssue, + reviewProvider: provider, + currentReview: + provider === 'gitlab' + ? (activeWorktree.linkedGitLabMR ?? activeReview.number) + : (activeWorktree.linkedPR ?? activeReview.number), + currentComment: activeWorktree.comment, + focus: 'pr', + suppressHostedReviewRefresh: true, + afterSave: async ({ + updates + }: { + updates?: { linkedPR?: unknown; linkedGitLabMR?: unknown } + }) => { + const actionGeneration = reviewLinkActionGenerationRef.current + 1 + reviewLinkActionGenerationRef.current = actionGeneration + const isActionCurrent = (): boolean => + reviewLinkScopeKeyRef.current === openedScopeKey && + reviewLinkActionGenerationRef.current === actionGeneration + if (!isActionCurrent()) { + return + } + if (provider === 'github') { + if (typeof updates?.linkedPR === 'number') { + await refreshLinkedGitHubPullRequest(updates.linkedPR) + } + return + } + const nextMR = updates?.linkedGitLabMR + if (typeof nextMR !== 'number') { + return + } + await fetchHostedReviewForBranch(repo.path, branch, { + repoId: repo.id, + repoOwnerExecutionHostId: activeWorktree.hostId, + linkedGitHubPR: null, + linkedGitLabMR: nextMR, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null + }) + } + }) + }, [ + activeReview, + activeWorktree, + activeWorktreeId, + branch, + fetchHostedReviewForBranch, + openModal, + refreshLinkedGitHubPullRequest, + repo, + reviewLinkScopeKey + ]) + + return { handleUnlinkReview, handleLinkAnotherReview } +} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index 338c61190c5..d367c6ef44c 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -8,6 +8,7 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { Repo } from '../../../../shared/repo-types' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { WorktreeMetaUpdateOptions } from '@/store/slices/worktree-helpers' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' @@ -61,7 +62,8 @@ const updateWorktreeMeta = vi.fn< ( id: string, - updates: Partial + updates: Partial, + options?: WorktreeMetaUpdateOptions ) => Promise<{ ok: true } | { ok: false; error: string }> >() const fetchLinearIssue = vi.fn<(...args: never[]) => Promise>() @@ -134,6 +136,9 @@ function openDialog( otherRepos?: { repoId: string; worktree?: Partial }[] modalRepoId?: string modalExecutionHostId?: string + modalReviewProvider?: 'github' | 'gitlab' + modalCurrentReview?: number + modalSuppressHostedReviewRefresh?: boolean linearViewerOrganizationUrlKey?: string } = {} ): void { @@ -171,6 +176,9 @@ function openDialog( worktreeId: options.worktreeId ?? worktree.id, ...(options.modalRepoId ? { repoId: options.modalRepoId } : {}), ...(options.modalExecutionHostId ? { executionHostId: options.modalExecutionHostId } : {}), + ...(options.modalReviewProvider ? { reviewProvider: options.modalReviewProvider } : {}), + ...(options.modalCurrentReview ? { currentReview: options.modalCurrentReview } : {}), + ...(options.modalSuppressHostedReviewRefresh ? { suppressHostedReviewRefresh: true } : {}), currentDisplayName: worktree.displayName, currentComment: worktree.comment, focus: 'comment' @@ -225,6 +233,28 @@ describe('WorktreeMetaDialog issue link row', () => { expect(issueInput().value).toBe('42') }) + it('seeds and saves the GitLab MR row through the GitLab slot', async () => { + openDialog({ + worktree: { linkedGitLabMR: 42 }, + modalReviewProvider: 'gitlab', + modalCurrentReview: 42, + modalSuppressHostedReviewRefresh: true + }) + const input = screen.getByPlaceholderText('MR ! or GitLab URL') + + expect(screen.getByText('GitLab MR')).toBeTruthy() + expect((input as HTMLInputElement).value).toBe('42') + fireEvent.change(input, { target: { value: '!43' } }) + await act(async () => fireEvent.click(saveButton())) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + expect(updateWorktreeMeta.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ linkedGitLabMR: 43 }) + ) + expect(updateWorktreeMeta.mock.calls[0]?.[1]).not.toHaveProperty('linkedPR') + expect(updateWorktreeMeta.mock.calls[0]?.[2]).toEqual({ suppressHostedReviewRefresh: true }) + }) + it('replaces a completed emoji shortcode in the display name', () => { openDialog() const displayNameInput = screen.getByRole('textbox', { name: 'Display Name' }) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index e714deb0453..d35d26e8055 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -9,11 +9,12 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { getDisplacedLinkLabels } from './worktree-issue-displacement' import { buildWorktreeMetaUpdates, + parseGitLabMergeRequestNumberForMetaField, parseGitHubWorkItemNumberForMetaField, + type WorktreeReviewProvider, type WorktreeMetaDraft, type WorktreeMetaSavedPayload, type WorktreeMetaSnapshot @@ -32,6 +33,7 @@ import { } from '../../../../shared/issue-link-input' import { parseExecutionHostId } from '../../../../shared/execution-host' import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' +import { WorktreeReviewLinkField } from './WorktreeReviewLinkField' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' @@ -66,6 +68,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const currentComment = typeof modalData.currentComment === 'string' ? modalData.currentComment : '' const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment' + const reviewProvider: WorktreeReviewProvider = + modalData.reviewProvider === 'gitlab' ? 'gitlab' : 'github' + const suppressHostedReviewRefresh = modalData.suppressHostedReviewRefresh === true const afterSave = typeof modalData.afterSave === 'function' ? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise) @@ -82,19 +87,25 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { currentProvider, isFolderWorkspace, liveLinks - } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId }) - // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet. - const currentPR = - typeof modalData.currentPR === 'number' - ? String(modalData.currentPR) - : worktree?.linkedPR != null - ? String(worktree.linkedPR) - : '' + } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId, executionHostId }) + // Why: ChecksPanel seeds the review it is looking at, which may not be linked yet. + const currentReview = + typeof modalData.currentReview === 'number' + ? String(modalData.currentReview) + : reviewProvider === 'gitlab' + ? worktree?.linkedGitLabMR != null + ? String(worktree.linkedGitLabMR) + : '' + : typeof modalData.currentPR === 'number' + ? String(modalData.currentPR) + : worktree?.linkedPR != null + ? String(worktree.linkedPR) + : '' const [displayNameInput, setDisplayNameInput] = useState('') const [issueInput, setIssueInput] = useState('') const [issueProvider, setIssueProvider] = useState('github') - const [prInput, setPrInput] = useState('') + const [reviewInput, setReviewInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) @@ -112,7 +123,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }) const issueInputRef = useRef(null) - const prInputRef = useRef(null) + const reviewInputRef = useRef(null) const textareaRef = useRef(null) const prevIsOpenRef = useRef(false) const displayNameInputRef = useRef(null) @@ -121,7 +132,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { setDisplayNameInput(currentDisplayName) setIssueInput(currentIssue) setIssueProvider(currentProvider) - setPrInput(currentPR) + setReviewInput(currentReview) setCommentInput(currentComment) // Why: the baseline is frozen with the seed instead of tracking the store. // A background `orca worktree set --linear-issue` while the dialog is open @@ -140,8 +151,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { prevIsOpenRef.current = isOpen const draft = useMemo( - () => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }), - [displayNameInput, issueInput, issueProvider, prInput, commentInput] + () => ({ displayNameInput, issueInput, issueProvider, reviewInput, commentInput }), + [displayNameInput, issueInput, issueProvider, reviewInput, commentInput] ) // Why: a URL names its provider unambiguously. A bare key does not — Linear @@ -188,14 +199,16 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { if (!worktreeId) { return false } - const trimmedPR = prInput.trim() + const trimmedPR = reviewInput.trim() // Same quadratic-parse bound as the issue field — this runs on every keystroke. const prValid = trimmedPR === '' || (!isWorkItemLinkQueryTooLarge(trimmedPR) && - parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null) + (reviewProvider === 'gitlab' + ? parseGitLabMergeRequestNumberForMetaField(trimmedPR) + : parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')) !== null) return !issueInvalid && prValid - }, [worktreeId, issueInvalid, prInput]) + }, [worktreeId, issueInvalid, reviewInput, reviewProvider]) const displacedLinkLabels = useMemo( () => @@ -227,11 +240,15 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { // spinner for the whole in-flight save. setSaveError(null) try { - const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks) + const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks, reviewProvider) - const result = executionHostId - ? await updateWorktreeMeta(worktreeId, updates, { executionHostId }) - : await updateWorktreeMeta(worktreeId, updates) + const result = + executionHostId || suppressHostedReviewRefresh + ? await updateWorktreeMeta(worktreeId, updates, { + ...(executionHostId ? { executionHostId } : {}), + ...(suppressHostedReviewRefresh ? { suppressHostedReviewRefresh: true } : {}) + }) + : await updateWorktreeMeta(worktreeId, updates) // Why: a failed save refetches and reverts the optimistic write. Closing // here would report success for an edit that silently undid itself, and // would discard the name, comment and PR changes in the same payload. @@ -257,10 +274,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }, [ worktreeId, executionHostId, + suppressHostedReviewRefresh, canSave, draft, snapshot, liveLinks, + reviewProvider, updateWorktreeMeta, closeModal, afterSave, @@ -301,7 +320,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } else if (focusField === 'issue') { issueInputRef.current?.focus() } else if (focusField === 'pr') { - prInputRef.current?.focus() + reviewInputRef.current?.focus() } else { textareaRef.current?.focus() } @@ -315,10 +334,15 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { )} - {translate( - 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7', - 'Edit issue links, pull request links, and notes for this workspace.' - )} + {reviewProvider === 'gitlab' + ? translate( + 'auto.components.sidebar.WorktreeMetaDialog.gitlabDescription', + 'Edit issue links, merge request links, and notes for this workspace.' + ) + : translate( + 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7', + 'Edit issue links, pull request links, and notes for this workspace.' + )} @@ -348,28 +372,13 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { onKeyDown={handleIssueKeyDown} /> -
- - setPrInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.077a4f7b5c', - 'PR # or GitHub URL' - )} - className="h-8 text-xs" - /> -

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.5ae06f40fd', - 'Paste a pull request URL, or enter a number. Leave blank to remove the link.' - )} -

-
+
diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx index 8eb6dc8a594..9787707bace 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agents.tsx @@ -150,7 +150,7 @@ export function CompactAgentSummaryButton({ key={group.state} className="inline-flex min-w-0 shrink-0 items-center gap-0.5 rounded-sm bg-worktree-sidebar/70 px-1 py-0.5" > - + {/* Why: same-state agent identities read as one status cluster; overlapping them saves width without merging different states. */} diff --git a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx index d1b24cca2ad..4cf122b0519 100644 --- a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx @@ -3,6 +3,8 @@ import React from 'react' import { cn } from '@/lib/utils' import { WorktreeCardHeader } from './worktree-card-header' import { WorktreeCardMetaRow } from './worktree-card-meta-row' +import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +import { WorktreeCardPortsDetails } from './WorktreeCardPorts' import type { WorktreeCardPresentation } from './worktree-card-presentation' import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' @@ -26,10 +28,91 @@ export function WorktreeCardParentContent({ handleToggleUnreadQuick, statusLaneReview, branchIdentityDisplay, - showInlineAgentList + showInlineAgentList, + titleRenaming, + isDeleting, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + metaAutomationProvenance, + metaCliProvenance, + workspacePorts, + detailsHoverControl, + handleRenameTitle, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + linearIssue, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview } = card - const { titleOnlyCard, parentContentMarginLeft, showCombinedStatusSlot, showUnreadQuickAction } = - presentation + const { + titleOnlyCard, + parentContentMarginLeft, + showCombinedStatusSlot, + showUnreadQuickAction, + hasHoverDetails, + hoverBranchName, + hoverWorkspaceTitle + } = presentation + + const identityContent = ( +
+ + {presentation.hasMetaRow && } +
+ ) + // Why: status glyphs and agent rows own their tooltips; only identity content should open the larger details card. + const identityContentWithHover = + hasHoverDetails && !titleRenaming ? ( + 0 ? : null + } + openDelay={100} + hoverControl={detailsHoverControl} + onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + hoverIssue && 'url' in hoverIssue && hoverIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {identityContent} + + ) : ( + identityContent + ) return (
- {/* Header row: Title */} - - {presentation.hasMetaRow && } + {identityContentWithHover}
diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index a9c91c78131..a045a7b2d32 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -4,8 +4,6 @@ import { LoaderCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' import WorktreeContextMenu from './WorktreeContextMenu' -import { WorktreeCardDetailsHover } from './WorktreeCardMeta' -import { WorktreeCardPortsDetails } from './WorktreeCardPorts' import { WorktreeCardParentContent } from './worktree-card-parent-content' import { buildWorktreeCardPresentation } from './worktree-card-presentation' import type { WorktreeCardController } from './use-worktree-card-controller' @@ -38,83 +36,13 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): handleDragStart, handleDragEnd, handleContextMenuSelect, - hoverIssue, - hoverLinearIssue, - hoverJiraIssue, - hoverReview, - hoverComment, - metaAutomationProvenance, - metaCliProvenance, - workspacePorts, - detailsHoverControl, - handleRenameTitle, - handleEditIssue, - handleEditComment, - handleOpenGitHubIssueInOrca, - linearIssue, - handleOpenLinearIssueInOrca, - handleOpenReviewInOrca, - handleOpenAutomation, - handleOpenAutomationRun, - hasExplicitLinkedReview, - handleUnlinkReview, showRenameErrorDialog, setShowRenameErrorDialog } = card - const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } = - presentation + const { titleOnlyCard, cardStyle } = presentation const parentCardContent = - const parentHoverTriggerBody = ( -
- {parentCardContent} -
- ) - - const parentCardBodyWithHoverDetails = - hasHoverDetails && !titleRenaming ? ( - 0 ? : null - } - openDelay={100} - hoverControl={detailsHoverControl} - onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} - onEditIssue={affiliateListMode ? undefined : handleEditIssue} - onEditComment={affiliateListMode ? undefined : handleEditComment} - onOpenGitHubIssueInOrca={ - hoverIssue && 'url' in hoverIssue && hoverIssue.url - ? handleOpenGitHubIssueInOrca - : undefined - } - onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} - onOpenReviewInOrca={ - hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined - } - onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} - onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} - // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. - onUnlinkReview={ - !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined - } - > - {parentHoverTriggerBody} - - ) : ( - parentHoverTriggerBody - ) - const cardBody = (
)} - {parentCardBodyWithHoverDetails} + {parentCardContent} {newCardStyle && lineageChildren ? (
{ expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() }) + + it.each(['status', 'agent'] as const)( + 'keeps the %s region in the lineage nesting hit zone', + (targetRole) => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + targetRole + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + } + ) }) describe('getReorderedWorktreeIdsToUnnest', () => { @@ -168,24 +184,30 @@ function makeTarget(args: { top: number bottom: number contained?: boolean + targetRole?: 'identity' | 'status' | 'agent' }): { container: HTMLElement target: Element } { - const row = { - getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) - } as HTMLElement - const content = { - getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), - closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) - } as HTMLElement - const target = { - closest: (selector: string) => - selector === '[data-worktree-card-hover-trigger]' ? content : null - } as Element + const container = document.createElement('div') + const row = document.createElement('div') + row.setAttribute('data-worktree-drag-id', args.worktreeId) + const content = document.createElement('div') + content.setAttribute('data-worktree-card-parent-content', '') + content.getBoundingClientRect = () => ({ top: args.top, bottom: args.bottom }) as DOMRect + const status = document.createElement('div') + const identity = document.createElement('div') + identity.setAttribute('data-worktree-card-hover-trigger', '') + const agent = document.createElement('div') + content.append(status, identity, agent) + row.append(content) + container.append(row) + + const targetByRole = { status, identity, agent } + const target = targetByRole[args.targetRole ?? 'identity'] const contained = args.contained ?? true - const container = { - contains: (element: Element) => contained && (element === content || element === row) - } as HTMLElement + if (!contained) { + container.removeChild(row) + } return { container, target } } diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index 5f63768aac3..a67c7ab3e43 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -2,7 +2,7 @@ import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { getLineageRenderInfo } from './worktree-lineage-projection' -const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-parent-content]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' const LINEAGE_DROP_ZONE_RATIO = 0.4 diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx index 57095344384..ca755e18271 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -1,18 +1,21 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' 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( - + + + ) } From 8cf7c6926e873cdc111a814e16e7a4cf36a72839 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:45:54 -0700 Subject: [PATCH 07/12] fix(mobile): create-worktree sheet dies after picking a source and loses the picked PR (#16917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep the create form on screen through drawer swaps and survive reconnects The create-worktree flow could reach a state where the shared modal host was mounted with no sheet in it: a full-screen transparent window that swallows every tap with no way out. Frame analysis of the reported recording and a live simulator repro both land on the same state - the form sheet laid out at the right frame with progress=1, backdrop painting, sheet not painted. - Keep the form sheet mounted through every drawer transition, so the host Modal is never on screen without a sheet, and drop the render-read pin ref. - Re-assert a pinned sheet's enter transform when it takes the window back from a fill picker; nothing re-applied it before. - Key the form session on hostId, not on the RpcClient object: useHostClient swaps that object on every reconnect, which silently remounted the form and threw away the picked source. - Run the pasted-item lookup concurrently with the provider fan-out instead of after it (measured 2631ms -> 1480ms for a typed PR number). * fix(mobile): remount the sheet view on window hand-back so a rebuilt native view repaints On-device confirmation showed the committed hand-back re-assert never reaches the native view: progress already sits at 1 and translateY at 0, so withTiming produces no style delta, and the dead screen stayed reachable (1/25 on the committed build; 1/9 with a sub-pixel value nudge, which lands on the stale native binding when the view was rebuilt with a new tag). Remounting the sheet's Animated.View on an epoch keyed to the hand-back mounts a fresh native view with the style computed from the current shared values - progress is already 1, so it paints in place with no visible animation. 0 dead in 50 attempts on the remount build under the same churn condition that reproduced the dead screen on base. LANE-REPORT.md carries the full confirmation evidence and limits. * chore: drop the stray lane report from the repo root It is a working artifact, not source, and the root directory guard blocks any new top-level entry. * test(mobile): assert the sheet subtree rebuild directly, not through a test-only prop The hand-back test proved the remount by reading an epoch-keyed nativeID that existed only for it — production markup shaped by a test, and an assertion a future refactor could satisfy without rebuilding anything. Count mounts of the sheet's content instead, which is the property the fix actually depends on, and drop the nativeID. Also stop typing test renderers as 'ReactTestRenderer | null'. The static analysis job installs no mobile/node_modules, so that type is unresolvable there and the union trips no-redundant-type-constituents on every added line. The hand-back re-assert is not dead code as the old comment implied: the drawer swap hands back at 166ms, before the 180ms enter animation ends. * fix(mobile): keep the create form when a render is thrown away The session key was built from counters mutated during render. A blurred screen suspends this subtree (react-native-screens freezes via react-freeze), so React runs the component and then discards that render — but the counter bumps survive it. The next committed render then produced a new key and remounted the form, throwing away the picked source for a host switch or a close that never committed. Hold the open epoch in state, which React discards with the render that set it, and put the host in the key directly instead of counting host changes. --- .../src/components/NewWorktreeModal.test.tsx | 144 +++++++++++++++- mobile/src/components/NewWorktreeModal.tsx | 33 ++-- .../bottom-drawer-window-handback.test.ts | 160 ++++++++++++++++++ .../src/components/mounted-bottom-drawer.tsx | 26 ++- ...new-worktree-form-sheet-visibility.test.ts | 49 +++--- .../new-worktree-form-sheet-visibility.ts | 16 +- ...use-new-worktree-drawer-navigation.test.ts | 63 +++++++ .../use-new-worktree-drawer-navigation.ts | 18 +- .../smart-source-paste-concurrency.test.ts | 59 +++++++ .../src/tasks/use-smart-workspace-source.ts | 103 +++++++---- 10 files changed, 569 insertions(+), 102 deletions(-) create mode 100644 mobile/src/components/bottom-drawer-window-handback.test.ts create mode 100644 mobile/src/components/use-new-worktree-drawer-navigation.test.ts create mode 100644 mobile/src/tasks/smart-source-paste-concurrency.test.ts diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 0bbdb462d4c..23aabfbff21 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, Suspense, type ReactElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' @@ -348,7 +348,11 @@ describe('NewWorktreeModal project targets', () => { renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient })) await Promise.resolve() }) - expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + // The swap must not restart the form session — see the reconnect test below. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'stale-client-name', + 'stale-client-name' + ]) resolveOldList?.({ ok: true, result: { repos } }) await flushUpdates() @@ -377,6 +381,142 @@ describe('NewWorktreeModal project targets', () => { expect(repoListCalls).toHaveLength(2) }) + // A reconnect / forceReconnect / foreground revival hands the same host a NEW + // RpcClient object (see useHostClient). Keying the form session on that object + // remounted the modal mid-edit and silently threw away the picked source. + it('keeps the picked source when a reconnect swaps the client for the same host', async () => { + const makeClient = () => + ({ + sendRequest: vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos } }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } + return new Promise(() => {}) + }) + }) as unknown as RpcClient + const modalProps = { + visible: true, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() })) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('feat/keep-me')) + + await act(async () => { + renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() })) + await Promise.resolve() + }) + + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'feat/keep-me', + 'feat/keep-me' + ]) + }) + + // react-native-screens freezes a blurred screen by suspending its subtree + // (react-freeze), so React runs this component and then throws that render away. + // Anything the render mutated in place outlives the work React discarded. + function suspendableTree(frozen: () => boolean, child: ReactElement) { + const never = new Promise(() => {}) + const Freezer = () => { + if (frozen()) { + throw never + } + return null + } + // The modal renders first, so its render completes before the freeze throws. + return createElement(Suspense, { fallback: null }, child, createElement(Freezer, null)) + } + + it('keeps the form when a host switch renders but never commits', async () => { + setCachedRepos('host-2', repos) + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } + let frozen = false + const tree = (hostId: string) => + suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, hostId })) + + await act(async () => { + renderer = create(tree('host-1')) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me')) + + frozen = true + await act(async () => { + renderer.update(tree('host-2')) + }) + frozen = false + await act(async () => { + renderer.update(tree('host-1')) + }) + + // host-2 never committed, so host-1's session was never superseded. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['keep-me', 'keep-me']) + }) + + it('keeps the form when a close renders but never commits', async () => { + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + let frozen = false + const tree = (visible: boolean) => + suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, visible })) + + await act(async () => { + renderer = create(tree(true)) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me-too')) + + frozen = true + await act(async () => { + renderer.update(tree(false)) + }) + frozen = false + await act(async () => { + renderer.update(tree(true)) + }) + + // The drawer never committed a closed state, so this is not a reopening. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'keep-me-too', + 'keep-me-too' + ]) + }) + + it('starts a fresh form session when the modal switches hosts', async () => { + setCachedRepos('host-2', repos) + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-1' })) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('host-one-name')) + + await act(async () => { + renderer.update(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-2' })) + }) + + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + }) + it('starts with fresh form state after closing and reopening', async () => { const client = { sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 92bac7d59bb..a986d347782 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { Keyboard } from 'react-native' import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' @@ -33,26 +33,25 @@ import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script' import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation' export function NewWorktreeModal(props: NewWorktreeModalProps) { - const openEpochRef = useRef(0) - const wasVisibleRef = useRef(false) - const clientEpochRef = useRef({ client: props.client, epoch: 0 }) - // Why: each drawer opening is a fresh form session; remounting resets local // form state before paint instead of clearing it in a visible-prop Effect. - if (props.visible && !wasVisibleRef.current) { - openEpochRef.current += 1 - } - wasVisibleRef.current = props.visible - if (clientEpochRef.current.client !== props.client) { - clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 } + // State, not a ref: react-native-screens freezes a blurred screen by suspending + // this subtree, and a counter bumped during a render React then throws away + // would restart the session for an opening that never committed. + const [session, setSession] = useState({ openEpoch: 0, visible: props.visible }) + if (session.visible !== props.visible) { + setSession({ + openEpoch: props.visible ? session.openEpoch + 1 : session.openEpoch, + visible: props.visible + }) } - return ( - - ) + // Why: key the session on the HOST, never on the RpcClient object. A reconnect, + // forceReconnect, or foreground revival swaps that object for the same host + // (see useHostClient), and keying on it silently remounted this form mid-edit + // and threw away the picked source. Every client-scoped hook below already + // drops responses from a superseded client, so no remount is needed for that. + return } function NewWorktreeModalContent(props: NewWorktreeModalProps) { diff --git a/mobile/src/components/bottom-drawer-window-handback.test.ts b/mobile/src/components/bottom-drawer-window-handback.test.ts new file mode 100644 index 00000000000..a1d9c790a69 --- /dev/null +++ b/mobile/src/components/bottom-drawer-window-handback.test.ts @@ -0,0 +1,160 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const withTimingCalls = vi.hoisted(() => [] as { to: number; duration: number | undefined }[]) +const sharedWrites = vi.hoisted(() => [] as { key: string; value: unknown }[]) + +vi.mock('react-native', () => ({ + BackHandler: { addEventListener: () => ({ remove: () => {} }) }, + Keyboard: { + addListener: () => ({ remove: () => {} }), + dismiss: () => {}, + metrics: () => null + }, + Modal: 'Modal', + Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { + create: (styles: T) => styles, + absoluteFillObject: {} + }, + View: 'View', + useWindowDimensions: () => ({ width: 440, height: 956 }) +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 62, bottom: 34, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => { + function makeShared(key: string, initial: number) { + let value = initial + return { + get value() { + return value + }, + set value(next: number) { + value = next + sharedWrites.push({ key, value: next }) + } + } + } + let sharedIndex = 0 + return { + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => makeShared(`shared-${sharedIndex++}`, initial), + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number, config?: { duration?: number }) => { + withTimingCalls.push({ to, duration: config?.duration }) + return to + }, + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } + } +}) + +import { MountedBottomDrawer } from './mounted-bottom-drawer' + +const noop = () => {} + +// Counts mounts of the sheet's CONTENT, so a test can tell an ordinary re-render +// apart from the subtree rebuild the fix relies on to repaint a stale native view. +const sheetBodyMounts = { count: 0 } +function SheetBody() { + useEffect(() => { + sheetBodyMounts.count += 1 + }, []) + return null +} + +function drawer(interactive: boolean) { + return createElement( + MountedBottomDrawer, + { visible: true, interactive, onClose: noop, onHidden: noop }, + createElement(SheetBody) + ) +} + +function render(interactive: boolean): ReactTestRenderer { + let renderer!: ReactTestRenderer + act(() => { + renderer = create(drawer(interactive)) + }) + return renderer +} + +function update(renderer: ReactTestRenderer, interactive: boolean): void { + act(() => { + renderer.update(drawer(interactive)) + }) +} + +// A sheet pinned under a fill picker keeps progress at 1 the whole time, so +// nothing re-applies its enter transform when the picker gives the window back. +// On device that left the create form laid out but unpainted — a dimmed screen +// with no sheet and no way back except dismissing the whole modal. +describe('bottom drawer window hand-back', () => { + afterEach(() => { + withTimingCalls.length = 0 + sharedWrites.length = 0 + sheetBodyMounts.count = 0 + }) + + it('re-asserts the enter transform when a pinned sheet takes the window back', () => { + const renderer = render(true) + update(renderer, false) + const beforeHandback = withTimingCalls.filter((call) => call.to === 1).length + + update(renderer, true) + + expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(beforeHandback + 1) + act(() => renderer.unmount()) + }) + + // Shared-value writes cannot heal a sheet whose native view was rebuilt under + // Reanimated (verified on device); only a fresh view repaints. Counting content + // mounts asserts the subtree actually rebuilt, not merely that a prop changed. + it('rebuilds the sheet subtree when it takes the window back', () => { + const renderer = render(true) + update(renderer, false) + const mountsWhilePinned = sheetBodyMounts.count + expect(mountsWhilePinned).toBe(1) + + update(renderer, true) + + expect(sheetBodyMounts.count).toBe(2) + act(() => renderer.unmount()) + }) + + it('does not re-assert or rebuild while the sheet stays pinned', () => { + const renderer = render(true) + update(renderer, false) + const pinned = withTimingCalls.filter((call) => call.to === 1).length + + update(renderer, false) + + expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(pinned) + expect(sheetBodyMounts.count).toBe(1) + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 3331d7298d8..cea320553f1 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { View, Pressable, @@ -89,6 +89,28 @@ export function MountedBottomDrawer({ }) : undefined + // Why: a sheet pinned under a fill picker holds progress at its target while the + // picker owns the window, so nothing re-applies its transform when the picker + // leaves. If the native view was rebuilt underneath, it keeps a stale transform + // and never paints — a dimmed, dead screen the user can only escape by dismissing + // the whole modal. A shared-value write alone cannot heal that (verified on + // device: an unchanged or nudged style lands on the stale native binding), so the + // remount is what repaints; the writes below keep the shared values authoritative + // for the fresh view, which matters because the drawer swap (166ms) hands back + // before the 180ms enter animation has finished. + const [windowEpoch, setWindowEpoch] = useState(0) + const wasInteractiveRef = useRef(interactive) + useEffect(() => { + const tookWindowBack = visible && interactive && !wasInteractiveRef.current + wasInteractiveRef.current = interactive + if (!tookWindowBack) { + return + } + translateY.value = 0 + progress.value = withTiming(1, { duration: SHOW_DURATION }) + setWindowEpoch((epoch) => epoch + 1) + }, [interactive, visible]) + useEffect(() => { if (visible) { translateY.value = 0 @@ -358,6 +380,8 @@ export function MountedBottomDrawer({ { - it('keeps the form under the source picker and its close transition', () => { + it('keeps the form under the source picker', () => { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'source' })).toBe( + true + ) + }) + + // The host Modal stays mounted across every drawer swap, so a transition that + // renders no sheet is a transparent tap-swallowing screen with no way out if + // the queued transition never lands. + it('never leaves the mounted modal without a sheet during a drawer swap', () => { expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'source', - formPinnedUnderSource: true - }) - ).toBe(true) - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'transition', - formPinnedUnderSource: true - }) + resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'transition' }) ).toBe(true) }) - it('hides the form for sequential repo/agent transitions', () => { - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'transition', - formPinnedUnderSource: false - }) - ).toBe(false) - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'project', - formPinnedUnderSource: false - }) - ).toBe(false) + it('yields the window to the content-sized pickers and the trust prompt', () => { + for (const drawerView of ['project', 'runTarget', 'agent', 'trust']) { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView })).toBe(false) + } + }) + + it('hides everything once the modal closes', () => { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: false, drawerView: 'form' })).toBe( + false + ) }) }) diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.ts b/mobile/src/components/new-worktree-form-sheet-visibility.ts index 57ad63c0ef6..5125f112b7c 100644 --- a/mobile/src/components/new-worktree-form-sheet-visibility.ts +++ b/mobile/src/components/new-worktree-form-sheet-visibility.ts @@ -1,16 +1,18 @@ -// Why: pin the create form under the fill-height name picker (and during that -// picker's close transition) so dismiss reveals the original content height. +// Why: the create form is the modal's floor. Every other drawer layers above it, +// so the shared modal host is never mounted with no sheet in it — a beat with a +// transparent full-screen host swallows taps, and a dropped transition timer +// would strand the user there with no way back (#16165 follow-up). export function resolveNewWorktreeFormSheetVisible(input: { modalVisible: boolean drawerView: string - formPinnedUnderSource: boolean }): boolean { if (!input.modalVisible) { return false } - if (input.drawerView === 'form' || input.drawerView === 'source') { - return true - } - return input.drawerView === 'transition' && input.formPinnedUnderSource + return ( + input.drawerView === 'form' || + input.drawerView === 'source' || + input.drawerView === 'transition' + ) } diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.test.ts b/mobile/src/components/use-new-worktree-drawer-navigation.test.ts new file mode 100644 index 00000000000..347e309c5e0 --- /dev/null +++ b/mobile/src/components/use-new-worktree-drawer-navigation.test.ts @@ -0,0 +1,63 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + useNewWorktreeDrawerNavigation, + type NewWorktreeDrawerView +} from './use-new-worktree-drawer-navigation' + +type Nav = ReturnType + +function renderNavigation(modalVisible: boolean): { current: Nav } { + const handle = { current: null as unknown as Nav } + function Probe(props: { modalVisible: boolean }) { + handle.current = useNewWorktreeDrawerNavigation(props.modalVisible) + return null + } + act(() => { + create(createElement(Probe, { modalVisible })) + }) + return handle +} + +describe('useNewWorktreeDrawerNavigation', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + // BottomDrawerModalHost keeps one native Modal mounted for the whole flow. A + // transition beat that renders no sheet is therefore a transparent full-screen + // window that eats every tap, and the queued timer is the only way out of it. + it('shows the form sheet for the whole transition, even if the queued timer never lands', () => { + const nav = renderNavigation(true) + act(() => nav.current.openSourceDrawer()) + expect(nav.current.drawerView).toBe('source') + + act(() => nav.current.transitionDrawer('form')) + expect(nav.current.drawerView).toBe('transition') + expect(nav.current.formSheetVisible).toBe(true) + expect(nav.current.formSheetInteractive).toBe(false) + }) + + it('keeps a sheet on screen while swapping to a content-sized picker', () => { + const nav = renderNavigation(true) + act(() => nav.current.transitionDrawer('agent')) + + expect(nav.current.drawerView).toBe('transition') + expect(nav.current.formSheetVisible).toBe(true) + + act(() => vi.advanceTimersByTime(500)) + expect(nav.current.drawerView).toBe('agent') + expect(nav.current.formSheetVisible).toBe(false) + }) + + it('hands the form back interactive once the transition lands', () => { + const nav = renderNavigation(true) + act(() => nav.current.openSourceDrawer()) + act(() => nav.current.transitionDrawer('form')) + act(() => vi.advanceTimersByTime(500)) + + expect(nav.current.drawerView).toBe('form') + expect(nav.current.formSheetVisible).toBe(true) + expect(nav.current.formSheetInteractive).toBe(true) + }) +}) diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.ts b/mobile/src/components/use-new-worktree-drawer-navigation.ts index 4f3261cb685..34dfe956e53 100644 --- a/mobile/src/components/use-new-worktree-drawer-navigation.ts +++ b/mobile/src/components/use-new-worktree-drawer-navigation.ts @@ -23,11 +23,10 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { openSourceDrawer: () => void } { const [drawerView, setDrawerView] = useState('form') - const formPinnedUnderSourceRef = useRef(false) const drawerTransitionTimerRef = useRef | null>(null) // Why: cancel any queued transition and reset when the modal closes, so a - // timer can't land after close and leave a stale drawer/pin for the next open. + // timer can't land after close and leave a stale drawer for the next open. useEffect(() => { if (modalVisible) { return @@ -36,7 +35,6 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { clearTimeout(drawerTransitionTimerRef.current) drawerTransitionTimerRef.current = null } - formPinnedUnderSourceRef.current = false setDrawerView('form') }, [modalVisible]) @@ -55,31 +53,23 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { setDrawerView('transition') drawerTransitionTimerRef.current = setTimeout(() => { drawerTransitionTimerRef.current = null - if (nextView === 'form') { - formPinnedUnderSourceRef.current = false - } setDrawerView(nextView) }, NEW_WORKTREE_DRAWER_TRANSITION_MS) } function openSourceDrawer(): void { - // Why: same-beat open; pin form under fill picker so outer content height - // is preserved when the name dialog dismisses. + // Why: same-beat open; the form stays mounted underneath so the outer + // content height is preserved when the fill picker dismisses. if (drawerTransitionTimerRef.current) { clearTimeout(drawerTransitionTimerRef.current) } drawerTransitionTimerRef.current = null - formPinnedUnderSourceRef.current = true setDrawerView('source') } return { drawerView, - formSheetVisible: resolveNewWorktreeFormSheetVisible({ - modalVisible, - drawerView, - formPinnedUnderSource: formPinnedUnderSourceRef.current - }), + formSheetVisible: resolveNewWorktreeFormSheetVisible({ modalVisible, drawerView }), formSheetInteractive: drawerView === 'form', transitionDrawer, openSourceDrawer diff --git a/mobile/src/tasks/smart-source-paste-concurrency.test.ts b/mobile/src/tasks/smart-source-paste-concurrency.test.ts new file mode 100644 index 00000000000..fe97fe462d3 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-concurrency.test.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useSmartWorkspaceSource } from './use-smart-workspace-source' + +function Probe(props: { client: RpcClient; query: string }) { + useSmartWorkspaceSource({ + client: props.client, + enabled: true, + mode: 'smart', + query: props.query, + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: false, + linearAvailable: false, + mrStateFilter: 'opened', + repos: [{ id: 'repo-1', displayName: 'orca', slug: { owner: 'stablyai', repo: 'orca' } }] + }) + return null +} + +// The picker makes two independent host round trips for a pasted PR number: the +// provider fan-out and the exact-item lookup. Awaiting the fan-out first stacked +// them, so the rows appeared a whole extra round trip late. +describe('smart source paste lookup concurrency', () => { + const mounted: ReactTestRenderer[] = [] + + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + act(() => { + for (const renderer of mounted) { + renderer.unmount() + } + }) + mounted.length = 0 + vi.useRealTimers() + }) + + it('issues the pasted-number lookup while the fan-out is still in flight', async () => { + const sent: string[] = [] + const sendRequest = vi.fn((method: string) => { + sent.push(method) + // Nothing ever settles: only requests issued concurrently can be observed. + return new Promise(() => {}) + }) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + mounted.push(create(createElement(Probe, { client, query: '16831' }))) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(sent).toContain('github.listWorkItems') + expect(sent).toContain('github.workItem') + }) +}) diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 86c6ca2204a..a57c9f17d7d 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -180,6 +180,64 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { } } +type PasteLookup = { paste: PasteResolved; crossRepoPrompt: SmartCrossRepoPrompt | null } + +const EMPTY_PASTE_LOOKUP: PasteLookup = { + paste: { github: null, gitlab: null }, + crossRepoPrompt: null +} + +// Resolves a pasted issue/PR/MR reference to the exact item it names. +async function resolvePastedItem(args: { + client: RpcClient + intent: NonNullable> + repoId: string + repos: readonly PasteRepoCandidate[] + repoSlugCache: Map +}): Promise { + const { client, intent, repoId, repos, repoSlugCache } = args + if (intent.kind === 'github-number') { + return { + paste: { + github: await lookupGitHubItemByNumber(client, repoId, intent.number), + gitlab: null + }, + crossRepoPrompt: null + } + } + if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + return { + paste: { github: null, gitlab: null }, + crossRepoPrompt: { link: intent.link, matchingRepo } + } + } + return { + paste: { + github: await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ), + gitlab: null + }, + crossRepoPrompt: null + } + } + return { + paste: { github: null, gitlab: await lookupGitLabItemByPath(client, repoId, intent.link) }, + crossRepoPrompt: null + } +} + async function runSmartSearch(args: { client: RpcClient mode: SmartNameMode @@ -199,42 +257,21 @@ async function runSmartSearch(args: { crossRepoPrompt: SmartCrossRepoPrompt | null }> { const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args - const fan = await fanOutSmartSearch(args) - const paste: PasteResolved = { github: null, gitlab: null } - let crossRepoPrompt: SmartCrossRepoPrompt | null = null - const intent = mode === 'branches' || dismissedPasteRef.current === query.trim() ? null : resolvePasteIntent(query) - if (intent && repoId) { - try { - if (intent.kind === 'github-number') { - paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) - } else if (intent.kind === 'github-link') { - const matchingRepo = await findRepoMatchingSlugForPaste( - client, - repos, - intent.link.slug, - repoSlugCache + // Why: the paste lookup and the provider fan-out hit different host endpoints, + // so awaiting the fan-out first stacked two full round trips on the one path a + // user is most likely to take — typing a PR/issue number. Run them together. + const [fan, pasteLookup] = await Promise.all([ + fanOutSmartSearch(args), + intent && repoId + ? resolvePastedItem({ client, intent, repoId, repos, repoSlugCache }).catch( + // Best-effort paste resolution; fall back to the fan-out results. + () => EMPTY_PASTE_LOOKUP ) - if (matchingRepo && matchingRepo.id !== repoId) { - crossRepoPrompt = { link: intent.link, matchingRepo } - } else { - paste.github = await lookupGitHubItemByOwnerRepo( - client, - repoId, - intent.link.slug, - intent.link.number, - intent.link.type - ) - } - } else if (intent.kind === 'gitlab-link') { - paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) - } - } catch { - // Best-effort paste resolution; fall back to the fan-out results. - } - } - return { fan, paste, crossRepoPrompt } + : Promise.resolve(EMPTY_PASTE_LOOKUP) + ]) + return { fan, paste: pasteLookup.paste, crossRepoPrompt: pasteLookup.crossRepoPrompt } } From 7abdf037d6933d9eec6a86637c658eefc345bd3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:54:21 -0700 Subject: [PATCH 08/12] Fix Windows daemon host pruning on unverifiable liveness (#16908) * Fix Windows daemon host prune liveness contract * Scope host prune evidence per version * Drop redundant default cases from exhaustive liveness switches All three switches consume ProcessLivenessVerdict/ProcessSignalEvidence values constructed in-process by inspectProcessSignal/inspectProcessLiveness; the union is never deserialized from a wire, RPC, or persisted record, so the defaults are genuinely unreachable. * Cover prune liveness gates and quarantine corrupt pid records * Make the prune delete-gate fail safe and refuse truncated pid salvage The prune switch shared #16900's delete-gate shape: an unhandled future verdict status fell through into rmSync, protected only by the lint exhaustiveness rule. Deletion is now opted into by a positively matched 'exited' via reclaimUnownedDaemonHostDir; a pinning test feeds an out-of-contract verdict and asserts the host dir survives. Pid salvage from corrupt records now requires the digit run to be terminated by a following non-digit byte. A tear inside the digits leaves a truncated prefix that is a different pid: probing it either quarantined a record on an unrelated process's death or, when the prefix collided with an immortal pid (Windows System pid 4), re-created the permanent prune veto for that record. Unterminated digits mean the writer died mid-write, so the record quarantines without consulting any probe. * fix(daemon): stop an in-flight pid publish from being read as a dead version publishDaemonPidFile creates the record before writing it (writeFileSync with flag 'wx'), so a concurrent launch can read a live daemon's record as empty. A two-process probe observed the empty window on 7 of 2273 reads. An empty record was not treated as corrupt at all: the parser's legacy bare-integer fallback coerces it to pid 0 (Number('') === 0) with appVersion null, so the scan skipped it as a pre-relocation daemon, left its version unpinned, and the prune reclaimed a running daemon's host image -- the exact destructive outcome this change exists to prevent, reached without any 'unverifiable' verdict. A pid that is not a positive integer names no process (process.kill(0, 0) probes the caller's own process group), so it is now a veto rather than a skip. Quarantine additionally refuses any record written in the last minute: an in-flight publish is by definition fresh, while a record left corrupt by a dead writer ages past the floor and is quarantined on a later launch. Fixed locally rather than in parseDaemonPidFile, whose null result also drives an unlink in daemon-stale-kill. Each gate is pinned by a test that fails individually when it is reverted. --------- Co-authored-by: Brennan Benson --- .../daemon/daemon-host-relocation.test.ts | 379 +++++++++++++++++- src/main/daemon/daemon-host-relocation.ts | 90 +++-- .../daemon-incarnation-evidence-types.ts | 5 + src/main/daemon/daemon-pid-file-parse.test.ts | 22 + src/main/daemon/daemon-pid-file-parse.ts | 20 + .../daemon/daemon-pid-record-quarantine.ts | 61 +++ .../daemon/daemon-process-inspection.test.ts | 43 ++ src/main/daemon/daemon-process-inspection.ts | 28 ++ 8 files changed, 617 insertions(+), 31 deletions(-) create mode 100644 src/main/daemon/daemon-pid-file-parse.test.ts create mode 100644 src/main/daemon/daemon-pid-record-quarantine.ts diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 78e4787c0f6..0d2323fd449 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -1,15 +1,17 @@ import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + utimesSync, writeFileSync } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' @@ -39,8 +41,10 @@ import { collectPinnedDaemonVersions, getRelocatedDaemonHost, materializeRelocatedDaemonHost, - pruneOldDaemonHosts + pruneOldDaemonHosts, + reclaimUnownedDaemonHostDir } from './daemon-host-relocation' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' let tempDir: string let installDir: string @@ -109,6 +113,8 @@ beforeEach(() => { }) afterEach(() => { + // A test that fails between spyOn and mockRestore must not leak its mock into later tests. + vi.restoreAllMocks() setProcessProp('platform', originalPlatform) setProcessProp('execPath', originalExecPath) setProcessProp('resourcesPath', originalResourcesPath) @@ -262,24 +268,380 @@ describe('getRelocatedDaemonHost', () => { }) }) +// Why: quarantine refuses records written in the last minute, because an in-flight publish is +// indistinguishable from a torn one. Age a record so it stands for a settled corrupt record. +function ageRecordPastQuarantineFloor(recordPath: string): void { + const aged = new Date(Date.now() - 5 * 60_000) + utimesSync(recordPath, aged, aged) +} + describe('pruneOldDaemonHosts', () => { it('removes unpinned non-current version dirs, keeping current and pinned', () => { const root = join(localAppDataDir, 'Orca', 'daemon-host') for (const v of ['9.9.9', '1.0.0', '2.0.0']) { mkdirSync(join(root, v), { recursive: true }) } - pruneOldDaemonHosts(new Set(['2.0.0'])) + pruneOldDaemonHosts({ + status: 'complete', + versionLiveness: new Map([['2.0.0', { status: 'live' }]]) + }) expect(existsSync(join(root, '9.9.9'))).toBe(true) expect(existsSync(join(root, '2.0.0'))).toBe(true) expect(existsSync(join(root, '1.0.0'))).toBe(false) }) + it('keeps a host when its pid liveness query is permission denied', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '8.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v8.pid'), + JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '8.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('access denied'), { code: 'EPERM' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([['8.0.0', { status: 'live' }]]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '8.0.0'))).toBe(true) + killSpy.mockRestore() + }) + + it('keeps a host when its pid liveness query is unavailable', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '7.0.0'), { recursive: true }) + mkdirSync(join(root, '6.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v7.pid'), + JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '7.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([ + ['7.0.0', { status: 'unverifiable', reason: 'the daemon process could not be queried' }] + ]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '7.0.0'))).toBe(true) + expect(existsSync(join(root, '6.0.0'))).toBe(false) + killSpy.mockRestore() + }) + + it('prunes nothing and never throws when the evidence is unverifiable', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + for (const v of ['1.0.0', '2.0.0']) { + mkdirSync(join(root, v), { recursive: true }) + } + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(() => + pruneOldDaemonHosts({ + status: 'unverifiable', + reason: 'the daemon runtime directory could not be read' + }) + ).not.toThrow() + + expect(existsSync(join(root, '1.0.0'))).toBe(true) + expect(existsSync(join(root, '2.0.0'))).toBe(true) + // The reason must reach the field log — an unobservable no-op is undiagnosable. + expect(warnSpy).toHaveBeenCalledWith( + '[daemon] Skipping daemon-host prune: the daemon runtime directory could not be read' + ) + warnSpy.mockRestore() + }) + + it('skips pruning when the runtime directory cannot be read', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + + const evidence = collectPinnedDaemonVersions(join(userDataDir, 'daemon-never-created')) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon runtime directory could not be read' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) + + it('keeps a version live when any of its pid records is live', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '7.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // Two protocol generations of the same app version: one daemon live, one exited. The live + // record must win the merged verdict whichever order the directory scan visits them. + writeFileSync( + join(runtimeDir, 'daemon-v7.pid'), + JSON.stringify({ pid: 5001, startedAtMs: null, appVersion: '7.0.0' }) + ) + writeFileSync( + join(runtimeDir, 'daemon-v8.pid'), + JSON.stringify({ pid: 5002, startedAtMs: null, appVersion: '7.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid: number) => { + if (pid === 5001) { + return true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([['7.0.0', { status: 'live' }]]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '7.0.0'))).toBe(true) + killSpy.mockRestore() + }) + + it('preserves a host dir for any verdict that is not positively exited', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + // Why: deliberate out-of-contract cast — deletion must require a positive 'exited' match, + // so a future verdict status the prune does not know preserves the host dir, not deletes it. + const futureVerdict = { + status: 'suspended', + reason: 'hypothetical future verdict' + } as unknown as ProcessLivenessVerdict + + pruneOldDaemonHosts({ + status: 'complete', + versionLiveness: new Map([['1.0.0', futureVerdict]]) + }) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + + reclaimUnownedDaemonHostDir(futureVerdict, join(root, '1.0.0')) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + + reclaimUnownedDaemonHostDir({ status: 'exited' }, join(root, '1.0.0')) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + }) + + it('quarantines a record torn inside the pid digits without probing the truncated prefix', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // A tear inside the digits of pid 12345 leaves the prefix 123 — a DIFFERENT pid. Probing + // it would attribute an unrelated (here: dead) process's verdict to this record; the + // writer of a mid-digits tear died mid-write, so quarantine must not consult any probe. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid":123') + ageRecordPastQuarantineFloor(pidPath) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + expect(killSpy).not.toHaveBeenCalled() + expect(existsSync(pidPath)).toBe(false) + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe('{"pid":123') + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('never lets an immortal-pid prefix turn a torn record into a permanent prune veto', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // Pid 41234 torn to the prefix 4 — the Windows System pid, which answers probes forever. + // Trusting it would re-create for this one record the eternal veto pruning must not have. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid":4') + ageRecordPastQuarantineFloor(pidPath) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('access denied'), { code: 'EPERM' }) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + expect(killSpy).not.toHaveBeenCalled() + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe('{"pid":4') + + // Next launch: the listing is complete again and the unowned host is reclaimed. + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('never quarantines a corrupt record that was just written', () => { + // A live daemon's record is created before it is written (writeFileSync 'wx'), so a + // concurrent launch can read it as empty. Quarantining it would strand the running daemon's + // record and let the NEXT launch reclaim its host image. + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: + 'the daemon pid file could not be parsed and was written too recently to quarantine: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + expect(existsSync(join(runtimeDir, 'daemon-v7.pid.corrupt'))).toBe(false) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + }) + + it('never treats a settled empty record as a valid pre-relocation daemon', () => { + // Number('') === 0, so the parser's legacy bare-integer fallback accepts an empty record as + // pid 0 with appVersion null. Skipping it as "pins no host dir" would leave the version + // unpinned and let the prune below reclaim a live daemon's host image. Aged past the + // quarantine floor so this pins the pid guard rather than the freshness guard. + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, ' ') + ageRecordPastQuarantineFloor(pidPath) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + }) + + it('vetoes pruning while a pid salvaged from a corrupt record still answers', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // A torn write preserves the pid prefix; the process behind it still answers, so the + // record may belong to a live daemon of unknown version and must keep its veto un-quarantined. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid": 4242, "startedAtMs": 17') + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: + 'the daemon pid file could not be parsed and salvaged pid 4242 may still be running: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('quarantines a corrupt record naming no live pid so pruning resumes next launch', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, 'not a daemon record') + ageRecordPastQuarantineFloor(pidPath) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Launch with the corrupt record: prune skips once, and the record is quarantined in place + // (bytes preserved) instead of vetoing every future launch. + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + expect(existsSync(pidPath)).toBe(false) + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe( + 'not a daemon record' + ) + + // Next launch: the listing is complete again and the unowned host is reclaimed. + const nextEvidence = collectPinnedDaemonVersions(runtimeDir) + expect(nextEvidence).toEqual({ status: 'complete', versionLiveness: new Map() }) + pruneOldDaemonHosts(nextEvidence) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + warnSpy.mockRestore() + }) + + it('vetoes pruning without quarantine when a pid record cannot be read', (ctx) => { + // The suite mocks process.platform; the chmod trick needs the REAL host to be POSIX. + if (originalPlatform === 'win32') { + return ctx.skip() + } + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '1.0.0' })) + chmodSync(pidPath, 0o000) + try { + readFileSync(pidPath) + return ctx.skip() // Running as root: the permission bit cannot make the read fail. + } catch { + // The read fails as intended. + } + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + // A read failure is transient (AV lock, vanished file): veto this launch, but leave the + // record alone so a launch that can read it re-evaluates from the real bytes. + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be read: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) + it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { const root = join(localAppDataDir, 'Orca', 'daemon-host') mkdirSync(join(root, '1.0.0'), { recursive: true }) hostApp.appPath = join(installDir, 'resources', 'app') installHostApp() - pruneOldDaemonHosts(new Set()) + pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map() }) // A Node host owns no daemon-host tree, so deleting under it would be reaching into a // directory layout it never created. expect(existsSync(join(root, '1.0.0'))).toBe(true) @@ -299,7 +661,12 @@ describe('collectPinnedDaemonVersions', () => { JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' }) ) const pinned = collectPinnedDaemonVersions(runtimeDir) - expect(pinned.has('7.0.0')).toBe(true) - expect(pinned.has('6.0.0')).toBe(false) + expect(pinned).toEqual({ + status: 'complete', + versionLiveness: new Map([ + ['7.0.0', { status: 'live' }], + ['6.0.0', { status: 'exited' }] + ]) + }) }) }) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 79e8bcf0ad7..a7e8f2b6db2 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -11,8 +11,10 @@ import { } from 'node:fs' import { dirname, join, win32 as winPath } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' import { parseDaemonPidFile } from './daemon-pid-file-parse' -import { startTimeMatches } from './daemon-process-start-time' +import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine' +import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-process-inspection' /** * Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it @@ -293,53 +295,93 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { return getRelocatedDaemonHost() } -function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { - try { - process.kill(pid, 0) - } catch { - return false - } - return startTimeMatches(pid, startedAtMs) -} +export type PinnedDaemonVersionsEvidence = + | { status: 'complete'; versionLiveness: ReadonlyMap } + | { status: 'unverifiable'; reason: string } /** * App versions still pinned by a live daemon (from daemon-v.pid files under `runtimeDir`), whose * host dir must not be reclaimed while alive. On win32 start-time can't verify, so a matching pid pins conservatively. */ -export function collectPinnedDaemonVersions(runtimeDir: string): Set { - const pinned = new Set() +export function collectPinnedDaemonVersions(runtimeDir: string): PinnedDaemonVersionsEvidence { + const versionLiveness = new Map() let entries try { entries = readdirSync(runtimeDir, { withFileTypes: true }) } catch { - return pinned + return { status: 'unverifiable', reason: 'the daemon runtime directory could not be read' } } for (const entry of entries) { if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { continue } - let parsed + let contents try { - parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + contents = readFileSync(join(runtimeDir, entry.name), 'utf8') } catch { - continue + // Read failures (AV lock, vanished file) are transient; the veto re-evaluates next launch. + return { + status: 'unverifiable', + reason: `the daemon pid file could not be read: ${entry.name}` + } + } + const parsed = parseDaemonPidFile(contents) + // Why not just `!parsed`: the parser's legacy bare-integer fallback coerces an empty or + // whitespace-only record to pid 0 (Number('') === 0), which is the exact shape a concurrent + // read sees while a live daemon publishes its record — writeFileSync 'wx' creates the file + // before writing it. Such a record would otherwise pass as a valid pre-relocation daemon, + // skip on appVersion === null, and leave its version unpinned, so the prune below would + // reclaim a running daemon's host image. A pid that is not a positive integer names no + // process — process.kill(0, 0) probes the caller's own process group, never a daemon — so + // it is not liveness evidence and must veto rather than be skipped. + if (!parsed || !Number.isInteger(parsed.pid) || parsed.pid <= 0) { + return { + status: 'unverifiable', + reason: quarantineCorruptDaemonPidRecord(runtimeDir, entry.name, contents) + } } // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. - if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { - pinned.add(parsed.appVersion) + if (parsed.appVersion === null) { + continue } + const verdict = inspectProcessLiveness(parsed.pid) + versionLiveness.set( + parsed.appVersion, + mergeProcessLivenessVerdict(versionLiveness.get(parsed.appVersion), verdict) + ) + } + return { status: 'complete', versionLiveness } +} + +// Why: deletion is the destructive direction and this is a statement position the compiler does +// not police for exhaustiveness — reclaim must be opted into by a positively matched 'exited', +// so any future unhandled verdict status preserves the host dir instead of deleting it. +export function reclaimUnownedDaemonHostDir( + verdict: ProcessLivenessVerdict, + hostDir: string +): void { + if (verdict.status !== 'exited') { + return + } + try { + rmSync(hostDir, { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. } - return pinned } /** * Reclaim daemon-host/ dirs that are neither the current version nor pinned by a live daemon. * Best-effort — never throws; a locked/staging dir is retried on a future launch. */ -export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { +export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): void { if (!isPackagedElectronWin32()) { return } + if (evidence.status === 'unverifiable') { + console.warn(`[daemon] Skipping daemon-host prune: ${evidence.reason}`) + return + } const version = getAppEnvironment().getVersion() const root = hostRootDir() let entries @@ -349,13 +391,11 @@ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { return } for (const entry of entries) { - if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + if (!entry.isDirectory() || entry.name === version) { continue } - try { - rmSync(join(root, entry.name), { recursive: true, force: true }) - } catch { - // Still locked or already gone — retry on a future launch. - } + // A complete runtime-dir listing with no pid record for this version proves it is unowned. + const verdict = evidence.versionLiveness.get(entry.name) ?? { status: 'exited' } + reclaimUnownedDaemonHostDir(verdict, join(root, entry.name)) } } diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts index 48c61da177f..4b2e1c7a69b 100644 --- a/src/main/daemon/daemon-incarnation-evidence-types.ts +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -54,6 +54,11 @@ export type DaemonProcessEvidence = export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable' +export type ProcessLivenessVerdict = + | { status: 'live' } + | { status: 'unverifiable'; reason: string } + | { status: 'exited' } + export type LinuxStatEvidence = | { status: 'present'; value: string } | { status: 'missing' } diff --git a/src/main/daemon/daemon-pid-file-parse.test.ts b/src/main/daemon/daemon-pid-file-parse.test.ts new file mode 100644 index 00000000000..5f9a5f10f58 --- /dev/null +++ b/src/main/daemon/daemon-pid-file-parse.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { salvagePidFromCorruptDaemonRecord } from './daemon-pid-file-parse' + +describe('salvagePidFromCorruptDaemonRecord', () => { + it('salvages a pid whose digit run is terminated by a following byte', () => { + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242,"startedAtMs":17')).toBe(4242) + expect(salvagePidFromCorruptDaemonRecord('{"pid": 4242, "startedAtMs"')).toBe(4242) + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242}')).toBe(4242) + }) + + it('refuses digits at end-of-bytes: a tear inside the digits leaves a different pid', () => { + // Pid 42420 torn mid-digits — the surviving prefix 4242 must not be mistaken for a pid. + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":4')).toBe(null) + }) + + it('refuses records with no usable pid field', () => { + expect(salvagePidFromCorruptDaemonRecord('not a daemon record')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":0,"startedAtMs":17')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":-42,')).toBe(null) + }) +}) diff --git a/src/main/daemon/daemon-pid-file-parse.ts b/src/main/daemon/daemon-pid-file-parse.ts index 18e85088eaf..70a9f9649a9 100644 --- a/src/main/daemon/daemon-pid-file-parse.ts +++ b/src/main/daemon/daemon-pid-file-parse.ts @@ -9,6 +9,26 @@ export type ParsedDaemonPid = { spawnerExecPath: string | null } +/** + * Best-effort pid recovery from a record parseDaemonPidFile rejected. The pid is the first key + * JSON.stringify writes, so a torn write usually preserves it; it gates whether a corrupt record + * may be quarantined (a process still answering for this pid keeps its conservative veto). + * + * The digit run must be terminated by a following non-digit byte: a torn write can cut inside + * the digits, and a truncated prefix is a different pid — probing it attributes an unrelated + * process's liveness to this record (a dead prefix would quarantine on false evidence; an + * immortal one, e.g. Windows System pid 4, would veto forever). Digits at end-of-bytes are + * therefore unsalvageable; the writer of such a prefix died mid-write, so no probe is needed. + */ +export function salvagePidFromCorruptDaemonRecord(contents: string): number | null { + const match = /"pid"\s*:\s*(\d+)(?=\D)/.exec(contents) + if (!match) { + return null + } + const pid = Number(match[1]) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { const trimmed = contents.trim() try { diff --git a/src/main/daemon/daemon-pid-record-quarantine.ts b/src/main/daemon/daemon-pid-record-quarantine.ts new file mode 100644 index 00000000000..577e56578d2 --- /dev/null +++ b/src/main/daemon/daemon-pid-record-quarantine.ts @@ -0,0 +1,61 @@ +import { renameSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { salvagePidFromCorruptDaemonRecord } from './daemon-pid-file-parse' +import { inspectProcessLiveness } from './daemon-process-inspection' + +/** + * A record that was just written is never quarantined: publishDaemonPidFile creates the record + * before writing it (writeFileSync with flag 'wx'), so a concurrent launch can read a LIVE + * daemon's record as empty or torn. Renaming it aside would strand that daemon's record, and the + * next launch — seeing a complete listing with no record for its version — would reclaim the + * running daemon's host image. An in-flight publish is by definition fresh; a record left corrupt + * by a dead writer ages past this floor and is quarantined on a later launch. + */ +const QUARANTINE_MIN_RECORD_AGE_MS = 60_000 + +/** + * A pid record that parses to nothing would otherwise veto daemon-host pruning on every future + * launch: nothing ever rewrites a retired protocol version's pid file, so the veto never expires. + * Quarantine the record (rename in place, bytes kept for diagnosis) so the next launch scans a + * complete listing again — unless a process still answers for a pid salvaged from the corrupt + * bytes, in which case the record may belong to a live daemon and keeps its conservative veto + * until that pid exits. Returns the unverifiable reason; every branch is logged because this + * state suppresses pruning. + */ +export function quarantineCorruptDaemonPidRecord( + runtimeDir: string, + name: string, + contents: string +): string { + const salvagedPid = salvagePidFromCorruptDaemonRecord(contents) + if (salvagedPid !== null && inspectProcessLiveness(salvagedPid).status !== 'exited') { + const reason = `the daemon pid file could not be parsed and salvaged pid ${salvagedPid} may still be running: ${name}` + console.warn(`[daemon] Keeping corrupt daemon pid record: ${reason}`) + return reason + } + const recordPath = join(runtimeDir, name) + let modifiedAtMs: number + try { + modifiedAtMs = statSync(recordPath).mtimeMs + } catch { + const reason = `the daemon pid file could not be parsed or aged: ${name}` + console.warn(`[daemon] ${reason}`) + return reason + } + // A future mtime (clock adjustment) reads as negative age and is treated as fresh. + if (Date.now() - modifiedAtMs < QUARANTINE_MIN_RECORD_AGE_MS) { + const reason = `the daemon pid file could not be parsed and was written too recently to quarantine: ${name}` + console.warn(`[daemon] Keeping corrupt daemon pid record: ${reason}`) + return reason + } + try { + renameSync(recordPath, join(runtimeDir, `${name}.corrupt`)) + } catch { + const reason = `the daemon pid file could not be parsed or quarantined: ${name}` + console.warn(`[daemon] ${reason}`) + return reason + } + const reason = `the daemon pid file could not be parsed and was quarantined: ${name}` + console.warn(`[daemon] ${reason}`) + return reason +} diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts index 9da55aeb816..41ea4e660b9 100644 --- a/src/main/daemon/daemon-process-inspection.test.ts +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + mergeProcessLivenessVerdict, queryWindowsProcess, readLinuxProcessStartedAtMs, readMacosProcessStartedAtMs, @@ -114,6 +115,48 @@ describe('daemon process inspection', () => { ).resolves.toBe(1_699_000_010_000) }) + // Several daemon-v.pid records can name the same app version; the merged verdict decides + // whether pruneOldDaemonHosts may delete that version's host dir, so a wrong winner deletes a + // live host. Precedence: live > unverifiable > exited, regardless of record order. + describe('mergeProcessLivenessVerdict', () => { + const unverifiable = { status: 'unverifiable', reason: 'probe failed' } as const + + it('keeps a live verdict when a later record for the same version reports exited', () => { + expect(mergeProcessLivenessVerdict({ status: 'live' }, { status: 'exited' })).toEqual({ + status: 'live' + }) + }) + + it('keeps a live verdict when a later record reports unverifiable', () => { + expect(mergeProcessLivenessVerdict({ status: 'live' }, unverifiable)).toEqual({ + status: 'live' + }) + }) + + it('never lets an exited record downgrade an unverifiable verdict', () => { + expect(mergeProcessLivenessVerdict(unverifiable, { status: 'exited' })).toEqual(unverifiable) + }) + + it('lets a live record supersede an earlier exited or unverifiable verdict', () => { + expect(mergeProcessLivenessVerdict({ status: 'exited' }, { status: 'live' })).toEqual({ + status: 'live' + }) + expect(mergeProcessLivenessVerdict(unverifiable, { status: 'live' })).toEqual({ + status: 'live' + }) + }) + + it('lets an unverifiable record upgrade an earlier exited verdict', () => { + expect(mergeProcessLivenessVerdict({ status: 'exited' }, unverifiable)).toEqual(unverifiable) + }) + + it('adopts the first verdict when there is no prior one', () => { + expect(mergeProcessLivenessVerdict(undefined, { status: 'exited' })).toEqual({ + status: 'exited' + }) + }) + }) + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( 'rejects unsafe Windows pid %s before command interpolation', async (pid) => { diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 149ea478fb4..4f40090bfee 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -4,6 +4,7 @@ import { promisify } from 'node:util' import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks } from './daemon-process-start-time' import type { LinuxStatEvidence, + ProcessLivenessVerdict, ProcessSignalEvidence, WindowsProcessEvidence } from './daemon-incarnation-evidence-types' @@ -32,6 +33,33 @@ export function inspectProcessSignal(pid: number): ProcessSignalEvidence { } } +export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict { + const signal = inspectProcessSignal(pid) + switch (signal) { + case 'occupied': + case 'permission_denied': + return { status: 'live' } + case 'missing': + return { status: 'exited' } + case 'unavailable': + return { status: 'unverifiable', reason: 'the daemon process could not be queried' } + } +} + +export function mergeProcessLivenessVerdict( + current: ProcessLivenessVerdict | undefined, + next: ProcessLivenessVerdict +): ProcessLivenessVerdict { + switch (next.status) { + case 'live': + return next + case 'unverifiable': + return current?.status === 'live' ? current : next + case 'exited': + return current ?? next + } +} + export async function readLinuxStat(pid: number): Promise { try { return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') } From 4cb013c0a9251275fa3d20ea33b45429e07aa6be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:57:57 -0700 Subject: [PATCH 09/12] Never let a non-owning provider answer a PTY presence question false during the daemon swap window (#16953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pty): answer unverifiable, not false, for presence questions during the daemon swap window During cold start the installed local provider is still the plain in-process LocalPtyProvider until daemon-init swaps in the daemon router. It does not own restored daemon PTY ids, but pty:hasPty and the runtime controller's sync hasPty still let it answer — and its "not in my table" false read as an observed absence: the renderer dead-session reconciler tears panes down on exactly that false, remount recovery refuses on it, and terminal.list records an observed absence instead of an unverifiable verdict. - pty:hasPty now waits for the local-provider startup barrier before choosing an answering provider (the same #7742 guard pty:kill uses), so the post-swap owner answers. - hasPtyFromRuntimeController is sync and cannot wait; while the startup barrier is unsettled it answers null (unverifiable), and it inherits the async probe's remote-handle guard: no locally routed provider may answer for a paired runtime handle. - SSH-owned ids keep answering from their own provider without waiting, and a registration without a startup barrier (headless/orcad) keeps the in-process provider's false authoritative (#12393). * test(pty): isolate the remote-handle guard from the swap-window gate * refactor(pty): arm the swap-window settle watcher once per startup promise * Gate pty:inspectProcess on the daemon-swap startup barrier During the cold-start swap window the routed local provider is still the pre-swap LocalPtyProvider, which does not own restored daemon ids; its answer about one is fabricated, and today reads as unavailable only because the inspection funnel happens to consult hasPty before the provider's own inspection. Completion-sensitive inspection must not ride on that internal ordering: defer until the swap lands, exactly like pty:kill (#7742) and pty:hasPty. SSH-owned ids and no-barrier (headless/orcad sole-owner, #12393) registrations keep answering immediately. The thrice-repeated barrier idiom is now one helper. --- .../pty-startup-swap-window-presence.test.ts | 268 ++++++++++++++++++ src/main/ipc/pty/ipc/inspect.ts | 28 +- src/main/ipc/pty/runtime/controller.ts | 2 +- src/main/ipc/pty/runtime/operations.ts | 29 +- 4 files changed, 319 insertions(+), 8 deletions(-) create mode 100644 src/main/ipc/pty-startup-swap-window-presence.test.ts diff --git a/src/main/ipc/pty-startup-swap-window-presence.test.ts b/src/main/ipc/pty-startup-swap-window-presence.test.ts new file mode 100644 index 00000000000..d0a511e86cd --- /dev/null +++ b/src/main/ipc/pty-startup-swap-window-presence.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from 'vitest' +import { makeDeferred } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers, registerSshPtyProvider } from './pty' +import { ptyOwnership } from './pty/provider/ownership-state' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +// During the cold-start daemon swap the installed local provider is still the plain +// in-process LocalPtyProvider; it does not own daemon-restored PTY ids, so its +// "no PTY" is fabricated, not observed. A confident false here tears down live +// panes (shouldReconcileMissingSession reconciles ONLY on false) and blocks +// input-undeliverable remount recovery. These suites pin: while the swap is in +// flight the presence answer is deferred (IPC) or unverifiable-null (sync), and +// the post-swap owner's answer is the one that lands. +describe('registerPtyHandlers daemon-swap-window presence', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + const registerWithStartupBarrier = ( + barrier: Promise, + runtime?: Record + ): void => { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup: () => barrier } + ) + } + + const installRuntimeControllerWithBarrier = ( + barrier: Promise + ): { hasPty: (ptyId: string) => boolean | null } => { + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerWithStartupBarrier(barrier, { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + }) + if (!controller) { + throw new Error('runtime controller was not installed') + } + return controller + } + + it('pty:hasPty defers a restored daemon id until the provider swap lands instead of answering a pre-swap false', async () => { + const barrier = makeDeferred() + registerWithStartupBarrier(barrier.promise) + + const pending = Promise.resolve( + handlers.get('pty:hasPty')!(null, { id: 'daemon-restored-pty' }) + ) as Promise + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + await Promise.resolve() + // Pre-fix this has already resolved false — the pre-swap LocalPtyProvider + // answered for a PTY it does not own, and the renderer reconciler treats + // exactly that false as authority to tear the pane down. + expect(settled).toBe(false) + + installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) + barrier.resolve() + + await expect(pending).resolves.toBe(true) + }) + + it('pty:hasPty answers SSH-owned ids from their provider without waiting on the local swap', async () => { + const barrier = makeDeferred() + const sshHasPty = vi.fn((id: string) => id === 'ssh:ssh-1@@pty-2') + registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never) + registerWithStartupBarrier(barrier.promise) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'ssh:ssh-1@@pty-2' })).resolves.toBe(true) + expect(sshHasPty).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + }) + + it('runtime controller hasPty answers null, not false, while the local provider swap is in flight', async () => { + const barrier = makeDeferred() + const controller = installRuntimeControllerWithBarrier(barrier.promise) + + // Pre-fix: the pre-swap LocalPtyProvider's ptyProcesses.has() answers a + // confident false for a daemon-owned id. terminal.list then records an + // observed absence (verdict forgotten) instead of unverifiable. + expect(controller.hasPty('daemon-restored-pty')).toBe(null) + + installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) + barrier.resolve() + + await vi.waitFor(() => { + expect(controller.hasPty('daemon-restored-pty')).toBe(true) + }) + }) + + it('runtime controller hasPty never answers a paired-runtime handle from the local registry', () => { + // No startup barrier: the remote-handle guard must hold on its own, not + // ride on the swap-window gate. Same routing hazard the async probe and + // pty:hasPty already guard — no locally routed provider can + // authoritatively answer for a remote host's PTY, so remote-scoped ids + // stay unknown, never absent. + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerPtyHandlers( + mainWindow as never, + { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } as never + ) + + expect(controller?.hasPty('remote:environment@@pty-1')).toBe(null) + }) + + it('runtime controller hasPty answers SSH-owned ids without waiting on the local swap', () => { + const barrier = makeDeferred() + const sshHasPty = vi.fn((id: string) => id === 'ssh-live-pty') + registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never) + ptyOwnership.set('ssh-live-pty', 'ssh-1') + try { + const controller = installRuntimeControllerWithBarrier(barrier.promise) + + expect(controller.hasPty('ssh-live-pty')).toBe(true) + expect(sshHasPty).toHaveBeenCalledWith('ssh-live-pty') + } finally { + ptyOwnership.delete('ssh-live-pty') + } + }) + + it('pty:inspectProcess defers a restored daemon id until the provider swap lands instead of answering from the non-owning provider', async () => { + const barrier = makeDeferred() + registerWithStartupBarrier(barrier.promise) + + const pending = Promise.resolve( + handlers.get('pty:inspectProcess')!(null, { id: 'daemon-restored-pty' }) + ) + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + // Pre-fix this has already resolved — the pre-swap LocalPtyProvider was + // consulted about a PTY it does not own. Its non-ownership happens to read + // as unavailable today only because the inspection funnel consults hasPty + // before the provider's own inspection; completion-sensitive evidence must + // come from the post-swap owner, not from that internal ordering. + expect(settled).toBe(false) + + installDaemonTestProvider({ + hasPty: (id: string) => id === 'daemon-restored-pty', + inspectProcess: vi.fn(async () => ({ + foregroundProcess: 'codex', + hasChildProcesses: true + })) + }) + barrier.resolve() + + await expect(pending).resolves.toEqual({ + foregroundProcess: 'codex', + hasChildProcesses: true + }) + }) + + it('pty:inspectProcess answers SSH-owned ids from their provider without waiting on the local swap', async () => { + const barrier = makeDeferred() + const sshInspect = vi.fn(async () => ({ + foregroundProcess: 'ssh-codex', + hasChildProcesses: true + })) + registerSshPtyProvider('ssh-1', { + hasPty: (id: string) => id === 'ssh:ssh-1@@pty-2', + inspectProcess: sshInspect + } as never) + registerWithStartupBarrier(barrier.promise) + + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'ssh:ssh-1@@pty-2' }) + ).resolves.toEqual({ foregroundProcess: 'ssh-codex', hasChildProcesses: true }) + expect(sshInspect).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + }) + + it('keeps the in-process provider authoritative when no startup barrier is configured', async () => { + // Headless/orcad installs the daemon before registerPtyHandlers and passes + // no barrier; the installed provider is then the sole owner (#12393) and + // its false stays an observed absence. + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerPtyHandlers( + mainWindow as never, + { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } as never + ) + + expect(controller?.hasPty('never-spawned-pty')).toBe(false) + await expect(handlers.get('pty:hasPty')!(null, { id: 'never-spawned-pty' })).resolves.toBe( + false + ) + // The sole owner's inspection answer stays immediate too: with no swap in + // flight there is no window in which its word could be fabricated. + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'never-spawned-pty' }) + ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, unavailable: true }) + }) +}) diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 96c84b45c74..9f99d1e099c 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -29,6 +29,16 @@ export function installPtyInspectIpcHandlers(deps: { const ipcMain = getPtyIpc() const { getLocalPtyProviderStartupPromise } = deps + // Why: wait for daemon startup before selecting the local provider for an id + // the swap may re-own (#7742); ids owned by an SSH connection never wait. + // renderer-kill.ts inlines this — pty:kill's listener teardown is + // ordering-sensitive and must not gain even a no-barrier microtask. + const awaitSwapWindow = async (id: string): Promise => { + await getLocalPtyProviderStartupPromise( + ptyOwnership.get(id) ?? parseAppSshPtyId(id)?.connectionId + ) + } + ipcMain.handle('pty:listSessions', async (): Promise => { const deduped = new Map() const admission = new PtyProcessListAdmission() @@ -117,6 +127,10 @@ export function installPtyInspectIpcHandlers(deps: { // authoritative dead. That is a fabricated answer about another host's PTY. return null } + // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, and + // its "no PTY" is exactly the false the renderer reconciler is allowed to + // close panes on. + await awaitSwapWindow(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null const provider = parsedSshId @@ -155,12 +169,14 @@ export function installPtyInspectIpcHandlers(deps: { ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. - if ( - typeof args?.id !== 'string' || - !args.id || - args.id.startsWith('remote:') || - !hasPtyProviderForInspection(args.id) - ) { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, so + // nothing it reports about one is an observation; the post-swap owner must + // answer completion-sensitive inspection. + await awaitSwapWindow(args.id) + if (!hasPtyProviderForInspection(args.id)) { return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } } return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index ff5a0e5ccd0..19adaec77de 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -60,7 +60,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), - hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), + hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), listProcesses: (connectionId, opts) => listProcessesFromRuntimeController(deps, connectionId, opts), listProcessesWithHostScope: (opts) => diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index bf3ca74af25..bb8032f9cfb 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -152,8 +152,35 @@ export async function clearBufferFromRuntimeController( } } -export function hasPtyFromRuntimeController(ptyId: string): boolean | null { +const settledLocalPtyProviderStartups = new WeakSet>() +const watchedLocalPtyProviderStartups = new WeakSet>() + +export function hasPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): boolean | null { try { + // Why: no locally routed provider can authoritatively answer for a + // remote host's PTY, so remote-scoped ids stay unknown, never absent. + if (ptyId.startsWith('remote:')) { + return null + } + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId + const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise && !settledLocalPtyProviderStartups.has(startupPromise)) { + // Why: a sync probe cannot wait out the cold-start daemon swap the way + // probePtyLiveness does, and the pre-swap provider's "no PTY" for a + // daemon-restored id is fabricated — answer unverifiable until the swap + // settles (docs/reference/ssh-execution-boundary.md rule 2). + if (!watchedLocalPtyProviderStartups.has(startupPromise)) { + watchedLocalPtyProviderStartups.add(startupPromise) + const markSettled = (): void => { + settledLocalPtyProviderStartups.add(startupPromise) + } + startupPromise.then(markSettled, markSettled) + } + return null + } return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null } catch { return null From fd9125ea8c7b347cd8b675a4095e31cd3c865d25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:45:58 -0700 Subject: [PATCH 10/12] feat(native-chat): Codex structured native chat restructure (#16729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): port structured Codex sessions from restructure-recovery Rebuilds the desktop structured native-chat implementation from brennanb2025/native-chat-restructure-recovery (tip 4e31c08db3) on top of current main as a single commit, scoped to the local Codex path. Ported: - Structured agent-session core: durable record store + single-writer lease, canonical journal, agent-session wire host/attach/eviction/subscribers, `agentSession.*` RPC surface (registered via ALL_RPC_METHODS; host-side mobile allowlist included for wire compat), pty write gate, transcript additions, and the Codex app-server adapter/launch resolution. - Renderer: NativeChatStructuredSession view/composer stack, structured launch path with the single-flight guard, local structured session tabs sync, activation gate + structured inventory (read-only `agentSession.handoffStatus` probe), agent-session tabs in the tab strip, AI-vault structured session activation, and the settings pane with the parent Experimental Chat UI toggle plus the nested "Use updated structured native chat" toggle. New sessions require both flags, agent codex, no prompt, and a local non-WSL, non-Windows-host execution host (structured-native-chat-availability). - Fixes 72c013cea6 (verified Codex launch recovery), 8ddbaf5e3d (defer native terminal view switching affordances), and 4e31c08db3 (release the launch gate after a visibility retry) with their regression tests, including the third-launch-after-retry guard case. - Cross-version agent-session wire test + CI lane, packaging entries (proper-lockfile, agent-tooling asar excludes), and the wire-compat doc section. Deliberately not ported: mobile/ changes, the Claude structured runtime (only the claude-transcript-branch-proof and claude-structured-owner-identity leaf modules remain, backing the kept TUI-recovery arms), the terminal↔chat adoption/handoff flow (`agentSession.adoptTerminal`/`requestHandoff`, the handoff request engine, TUI adoption machinery, orca-runtime adoption methods), renderer switching affordances and their dead leftovers, the hook/subagent-status refactor cluster, and unrelated branch changes. The crash-during-acquisition recovery path (restart handoff adjudication, restore/reverse re-acquire, lease schema handoff keys) is kept because every plain direct launch depends on it; a trimmed handoff coordinator exposes only status/restore/close. Branch edits that targeted files main has since split (ipc/pty.ts, worktrees.ts, rpc/methods/terminal.ts, useIpcEvents, pty-connection, store/slices/terminals.ts, runtime-types, web preload) were re-applied to the split modules, preserving main's newer logic (Windows CIM fallback, browser tab close rework, cold-restore resume flow, dispatcher threading). Known seam: the mobile clipboard image-provenance CONSUMER gate ships (agentSession.send refuses unproven mobile image refs with agent_session_image_untrusted) but the producer hunk in rpc/methods/clipboard.ts stays with the unported mobile cluster, so mobile image sends into structured chat fail closed until that side ports. * fix(native-chat): trust only authenticated local image uploads * fix(build): preserve Windows process-tree patch application * test(windows): include process creation time in addon fixture * fix(build): run windows-process-tree node-gyp from the physical package dir gyp expands the node-addon-api dependency by probing node, whose cwd resolves to the package's physical directory in the store, so the emitted target is a store-relative ../../../../node-addon-api@... hop. gyp then resolves that hop against the rebuild cwd; from the node_modules symlink/junction it escapes the store and configure fails with "node_addon_api.gyp not found" (run 32999886072). Rebuild from realpath(package dir) so both bases agree, matching how the package manager itself runs native install scripts. The regression test replays gyp's expansion+resolution against the planned cwd and fails without the fix. * fix(native-chat): keep chat tabs visible through terminal closes and empty-worktree launches Two proven blockers in the native Codex tab contract: closeTerminalTab pre-empted the canonical unified close. With one terminal left it deactivated the worktree on a terminal/editor/browser-only check, blanking a workspace that still held a renderable agent-session tab; with two or more it pre-picked a successor from terminal entities only, re-stamping the group active before closeUnifiedTab's MRU/neighbor repair could land on the chat tab. Successor choice now defers to the unified contract whenever the terminal has a unified row, and deactivation is gated on the unified renderable count (matching leaveWorktreeIfEmpty), with the legacy pre-pick kept only for terminals without a unified row. A structured session created on an empty worktree was published into the host's headless group while preserveLocalLayout froze the local layout, leaving the tab in store but permanently off screen. A preserveLocalLayout owner now always takes client-owned placement — repairing a rendered leaf whose group record is missing, or materializing a rendered group on a truly empty worktree — and applies the client-derived layout repair while still rejecting host-authored layout. Regression tests drive the real store through closeTerminalTab (git worktree and folder workspace) and the real snapshot applier for the empty-worktree adoption states; all fail without the fixes. * fix(native-chat): close stale turns and retry rejected sends * fix(native-chat): retire hosted rows on structured tab activation * fix(native-chat): preserve rpc defaults across main merge * chore: format remote wire compatibility guide * test(native-chat): cover retry after unconfirmed send * fix(native-chat): reload outbox on session switch * docs(settings): disclose structured chat platform limits * fix(native-chat): await Codex launch-home preparation * fix(codex): align child-process allowlist with async trust bridge * test(identity): update inventory for tab surface refactor * fix(windows): preserve process-tree CRLF patch sources * fix(native-chat): anchor an unmatched chat echo where it was sent (#16117) * fix(native-chat): anchor an unmatched chat echo where it was sent The reported symptom was old user messages replaying below every new turn, so the conversation read as scrambled. The cause was not that the echo failed to match a transcript row. Claude consumes a mid-turn send through a `queued_command` attachment and writes no `type:"user"` record for it, so some echoes can never match, and no amount of matching will change that. The cause was WHERE an unmatched echo rendered: buildMobileNativeChatTransientData appended every pending item after the entire transcript, so it re-read below each turn that landed afterwards. Render each echo directly after the transcript row it was sent against, using the baseline the send already captures. An unmatched echo is then at worst a duplicate in the right position rather than a scrambled one, and it stays visible. Echoes sharing an anchor keep send order; a send with no baseline, or one whose anchor folding dropped, still falls back to the tail. Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an echo can never match, then removing it, loses the user's own text for a message the agent did receive, and it cannot fire in the common case anyway - measured drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing gap: the count pass has no baseline-tail guard, unlike the glue pass, while `messages` is a 40-row window that head-trims, resets on reconnect and grows at the front on loadEarlier, so a false landing there would license deleting a DIFFERENT outstanding message. That count-pass gap is real and left for a separate change; anchoring makes its worst case a duplicate in place rather than a scrambled conversation. * fix(native-chat): preserve folded echo anchors * fix(native-chat): preserve forward-folded echo anchors * fix(native-chat): keep leading folded echoes in place * fix(workspace-cleanup): show git status for every row (#16690) * fix(native-chat): refuse structured chat on every Windows execution path canUseStructuredNativeChat only refused win32 when a project runtime resolved, so folder-workspace keys (and other keys with no project runtime) failed open into structured chat on Windows. Fail closed on win32 unconditionally after the host check, matching the settings copy: local macOS/Linux only; Windows/WSL/SSH stay on terminal chat. * fix(native-chat): restore runtime refusals behind the win32 gate 506d375de3 replaced the project-runtime checks with a bare platform test, so a WSL or repair-required runtime resolution would no longer refuse structured chat off-win32. Keep the unconditional win32 refusal and re-run the runtime resolution after it, so the gate does not depend on the resolver's own platform guard. Tests inject WSL and repair-required resolutions on darwin/linux and fail against the regressed gate. * fix structured session journal durability * fix structured tab active pointer after restart * fix(native-chat): await optional lease renewal callbacks * refactor(skills): extract install error messages * fix(agent-session): harden recovery ownership * fix(native-chat): retain panes across tab activation * fix(native-chat): address round-one review findings * test(native-chat): align integration coverage after main merge * fix(native-chat): harden round-two reliability * fix(native-chat): harden round-three reliability * fix(native-chat): close round-four recovery gaps * fix(native-chat): separate bounded journal key forms * fix(native-chat): reset outbox error in render on session switch The switch effect adjusted error state after the sessionId prop changed, tripping react-doctor's no-adjust-state-on-prop-change on the changed-code gate and flashing the old session's banner for a frame. Reset it with the render-time previous-value guard instead. * fix(native-chat): invalidate stale outbox settlements * test(native-chat): restore settled-error session-switch regression a6e2379bd1 replaced this test with the in-flight settlement race test, leaving the render-time error reset unpinned: deleting the reset block still passed the whole native-chat suite. Keep both scenarios pinned; they are distinct (settled error clears on switch vs stale settlement invalidated in the commit-to-passive window). * test(wire): make release checkouts race safe * test(wire): pin cross-process checkout single-flight and importer specifier contract * test(wire): harden release checkout lifecycle * fix(build): drop CR-byte residue from windows-process-tree patch The two trailing CR bytes on the patch's deletion lines are a proven no-op: pnpm hashes patches CRLF-normalized (both forms hash to the lockfile's 946ffb2b) and materializes this package without applying the patch in either form, so the load-bearing build edits come solely from applyWindowsProcessTreeBuildFixes() (#16947), which handles both source EOL forms. Restore byte-identity with main and repin the contract test to the post-#16947 reality: LF-only patch bytes plus lockfile hash sync. * fix(native-chat): skip empty startup recovery --- .github/workflows/pr.yml | 2 + config/electron-builder.config.cjs | 3 + config/packaged-runtime-node-modules.cjs | 1 + ...build-windows-process-tree-relay-addon.mjs | 14 +- .../scripts/electron-builder-config.test.mjs | 21 +- config/scripts/pr-code-change-scope.mjs | 10 + config/scripts/pr-code-change-scope.test.mjs | 16 + .../windows-process-tree-gyp-rebuild.mjs | 33 + .../windows-process-tree-gyp-rebuild.test.mjs | 29 + ...ndows-process-tree-patch-contract.test.mjs | 22 + docs/reference/remote-wire-compatibility.md | 27 +- package.json | 2 + pnpm-lock.yaml | 14 +- .../handlers/orchestration-worker-cli.test.ts | 46 + src/cli/handlers/terminal.test.ts | 41 + src/cli/handlers/terminal.ts | 3 + src/cli/terminal-format.ts | 7 + .../session-list-result-validation.ts | 6 + .../structured-session-ownership.test.ts | 143 ++ .../ai-vault/structured-session-ownership.ts | 186 +++ .../claude-structured-owner-identity.ts | 21 + .../claude/claude-transcript-branch-proof.ts | 128 ++ ...me-system-resource-materialization.test.ts | 2 +- .../service-account-add-login.test.ts | 2 +- .../codex/codex-app-server-client.test.ts | 11 +- .../codex-app-server-connection-types.ts | 27 + .../codex/codex-app-server-connection.test.ts | 549 +++++++ src/main/codex/codex-app-server-connection.ts | 349 ++++ src/main/codex/codex-app-server-exit-error.ts | 19 + .../codex-app-server-handshake-exit-proof.ts | 26 + src/main/codex/codex-app-server-handshake.ts | 22 + src/main/codex/codex-app-server-jsonl.ts | 12 + .../codex-app-server-notification-schema.ts | 78 + .../codex-app-server-posix-supervisor.test.ts | 61 + .../codex-app-server-posix-supervisor.ts | 128 ++ .../codex-app-server-process-teardown.test.ts | 148 ++ .../codex-app-server-process-teardown.ts | 183 +++ .../codex/codex-app-server-request-error.ts | 15 + src/main/codex/codex-app-server-session.ts | 30 +- ...ex-app-server-teardown.integration.test.ts | 140 ++ .../codex/codex-resume-process-proof.test.ts | 123 ++ src/main/codex/codex-resume-process-proof.ts | 101 ++ .../codex-server-request-disposition.test.ts | 127 ++ .../codex/codex-server-request-disposition.ts | 86 + ...-structured-acquisition-exit-proof.test.ts | 169 ++ .../codex-structured-acquisition-lifecycle.ts | 53 + .../codex-structured-acquisition-window.ts | 33 + .../codex-structured-app-server-args.test.ts | 32 + .../codex/codex-structured-app-server-args.ts | 84 + ...codex-structured-child-environment.test.ts | 25 + .../codex-structured-child-environment.ts | 13 + .../codex/codex-structured-item-streams.ts | 176 ++ .../codex-structured-item-translation.test.ts | 239 +++ .../codex-structured-item-translation.ts | 321 ++++ ...dex-structured-journal-translation.test.ts | 785 +++++++++ .../codex-structured-journal-translation.ts | 335 ++++ ...codex-structured-launch-resolution.test.ts | 168 ++ .../codex-structured-launch-resolution.ts | 81 + .../codex-structured-location-support.ts | 11 + .../codex-structured-owner-identity.test.ts | 48 + .../codex/codex-structured-owner-identity.ts | 64 + .../codex-structured-prompt-items.test.ts | 180 ++ .../codex/codex-structured-prompt-items.ts | 188 +++ .../codex-structured-prompt-replies.test.ts | 141 ++ .../codex/codex-structured-prompt-replies.ts | 209 +++ .../codex/codex-structured-provider-events.ts | 77 + .../codex-structured-session-adapter.test.ts | 883 ++++++++++ .../codex/codex-structured-session-adapter.ts | 325 ++++ .../codex-structured-session-cancel.test.ts | 279 ++++ .../codex/codex-structured-session-close.ts | 89 + .../codex-structured-session-options.test.ts | 171 ++ .../codex/codex-structured-session-options.ts | 203 +++ .../codex-structured-session-shutdown.test.ts | 102 ++ .../codex/codex-structured-session-state.ts | 166 ++ .../codex/codex-structured-thread-facts.ts | 39 + .../codex/codex-structured-thread-open.ts | 61 + .../codex-structured-turn-cancellation.ts | 154 ++ ...uctured-turn-processes.integration.test.ts | 103 ++ .../codex/codex-structured-turn-processes.ts | 85 + src/main/codex/codex-structured-turn-start.ts | 128 ++ ...tui-resume-real-binary.integration.test.ts | 160 ++ .../codex/codex-tui-rollout-proof.test.ts | 205 +++ src/main/codex/codex-tui-rollout-proof.ts | 247 +++ .../daemon/daemon-pty-session-inventory.ts | 1 + src/main/durable-file-write.ts | 67 +- src/main/index.ts | 13 + src/main/ipc/ai-vault-resume.ts | 4 + src/main/ipc/ai-vault.ts | 8 +- .../ipc/desktop-runtime-sender-lifecycle.ts | 72 + .../ipc/pty-agent-session-write-gate.test.ts | 290 ++++ .../pty/agent-session-write-refusal-report.ts | 20 + src/main/ipc/pty/ipc/write-input.ts | 65 +- src/main/ipc/pty/runtime/controller.ts | 5 +- src/main/ipc/pty/runtime/operations.ts | 30 +- .../register-core-handlers.ts | 1 + .../ipc/runtime-subscribe-lifecycle.test.ts | 315 ++++ src/main/ipc/runtime.test.ts | 42 +- src/main/ipc/runtime.ts | 84 +- .../worktrees-listing-fallback-rows.test.ts | 7 + .../register-worktree-catalog-handlers.ts | 9 +- .../journal-blob-store.ts | 92 ++ .../journal-compaction.ts | 172 ++ .../journal-corruption-quarantine.ts | 72 + .../journal-crash-boundary.test.ts | 382 +++++ .../journal-cursor.test.ts | 106 ++ .../agent-session-journal/journal-cursor.ts | 98 ++ .../journal-epoch-replacement.ts | 103 ++ .../journal-epoch-rollover.ts | 56 + .../journal-item-identity.test.ts | 299 ++++ .../journal-legacy-identity.ts | 87 + .../journal-legacy-import.test.ts | 507 ++++++ .../journal-legacy-import.ts | 241 +++ .../journal-log-file.test.ts | 374 +++++ .../agent-session-journal/journal-log-file.ts | 336 ++++ .../agent-session-journal/journal-open.ts | 186 +++ .../agent-session-journal/journal-paths.ts | 42 + .../journal-payload-bounds.ts | 86 + .../journal-pending-submission-recovery.ts | 18 + .../journal-reducer.test.ts | 446 +++++ .../agent-session-journal/journal-reducer.ts | 257 +++ .../journal-row-builders.ts | 168 ++ .../journal-row-schema.test.ts | 192 +++ .../journal-row-schema.ts | 200 +++ .../journal-store-contracts.ts | 42 + .../journal-store.test.ts | 756 +++++++++ .../agent-session-journal/journal-store.ts | 361 ++++ .../journal-submission-reconciler.ts | 191 +++ .../journal-write-guards.ts | 86 + .../agent-session-delta-coalescer.test.ts | 130 ++ .../agent-session-delta-coalescer.ts | 93 ++ .../agent-session-history-page.test.ts | 632 +++++++ .../agent-session-history-page.ts | 349 ++++ .../agent-session-journal-batch.ts | 82 + .../agent-session-journal-recovery.test.ts | 176 ++ .../agent-session-journal-recovery.ts | 114 ++ .../claude-stream-json-frame-schema.ts | 48 + .../provider-frame-disposition.test.ts | 92 ++ .../provider-frame-disposition.ts | 245 +++ ...-agent-session-acquisition-options.test.ts | 348 ++++ .../structured-agent-session-adapter.test.ts | 45 + .../structured-agent-session-adapter.ts | 149 ++ ...structured-agent-session-attach-context.ts | 34 + .../structured-agent-session-attach-flow.ts | 300 ++++ ...ured-agent-session-attach-orchestration.ts | 93 ++ .../structured-agent-session-attach.ts | 222 +++ ...uctured-agent-session-dead-tui-recovery.ts | 35 + ...tructured-agent-session-event-sink.test.ts | 130 ++ .../structured-agent-session-event-sink.ts | 144 ++ ...uctured-agent-session-eviction-deadline.ts | 51 + .../structured-agent-session-eviction.test.ts | 168 ++ .../structured-agent-session-eviction.ts | 102 ++ ...ured-agent-session-handoff-flow-context.ts | 86 + ...tructured-agent-session-handoff-forward.ts | 216 +++ ...tured-agent-session-handoff-owner-close.ts | 34 + ...tured-agent-session-handoff-restart-tui.ts | 98 ++ ...tructured-agent-session-handoff-restart.ts | 306 ++++ ...tructured-agent-session-handoff-reverse.ts | 146 ++ .../structured-agent-session-handoff-state.ts | 38 + ...structured-agent-session-handoff-status.ts | 166 ++ .../structured-agent-session-handoff-types.ts | 112 ++ .../structured-agent-session-handoff.test.ts | 384 +++++ .../structured-agent-session-handoff.ts | 63 + ...structured-agent-session-history-result.ts | 40 + .../structured-agent-session-hold-resume.ts | 52 + .../structured-agent-session-holders.ts | 49 + .../structured-agent-session-holds.test.ts | 231 +++ .../structured-agent-session-holds.ts | 103 ++ ...uctured-agent-session-host-handoff.test.ts | 30 + .../structured-agent-session-host-handoff.ts | 225 +++ .../structured-agent-session-host-lifetime.ts | 87 + ...structured-agent-session-host-mutations.ts | 115 ++ ...d-agent-session-host-runtime-state.test.ts | 101 ++ ...ctured-agent-session-host-runtime-state.ts | 99 ++ .../structured-agent-session-host-tabs.ts | 20 + ...structured-agent-session-host-test-data.ts | 63 + .../structured-agent-session-host-types.ts | 53 + .../structured-agent-session-host.test.ts | 879 ++++++++++ .../structured-agent-session-host.ts | 299 ++++ ...tructured-agent-session-launch-env.test.ts | 28 + .../structured-agent-session-launch-env.ts | 33 + .../structured-agent-session-lease-release.ts | 32 + ...ctured-agent-session-lease-renewer.test.ts | 292 ++++ .../structured-agent-session-lease-renewer.ts | 156 ++ ...-session-live-tui-restart-survival.test.ts | 246 +++ ...ctured-agent-session-mutation-admission.ts | 148 ++ ...structured-agent-session-mutation-plans.ts | 139 ++ ...ured-agent-session-operation-settlement.ts | 33 + .../structured-agent-session-option-error.ts | 13 + ...ctured-agent-session-option-restoration.ts | 20 + ...ed-agent-session-option-settlement.test.ts | 266 +++ ...nt-session-processless-reservation.test.ts | 192 +++ ...red-agent-session-provider-restore.test.ts | 100 ++ ...ructured-agent-session-provider-support.ts | 25 + .../structured-agent-session-read-restore.ts | 88 + ...ed-agent-session-readable-restorer.test.ts | 43 + ...uctured-agent-session-readable-restorer.ts | 52 + ...tured-agent-session-recovery-exits.test.ts | 344 ++++ ...-agent-session-recovery-resolution.test.ts | 300 ++++ ...tured-agent-session-recovery-resolution.ts | 124 ++ ...tructured-agent-session-refusal-message.ts | 45 + ...ctured-agent-session-refusal-retry.test.ts | 372 +++++ .../structured-agent-session-registry.ts | 18 + .../structured-agent-session-release-clock.ts | 75 + ...structured-agent-session-replay-outcome.ts | 60 + ...ed-agent-session-restart-reconcile.test.ts | 73 + ...uctured-agent-session-restart-reconcile.ts | 68 + ...agent-session-restart-restore-gate.test.ts | 27 + ...ured-agent-session-restart-restore-gate.ts | 17 + ...ured-agent-session-restart-restore.test.ts | 59 + ...tructured-agent-session-restart-restore.ts | 60 + ...ctured-agent-session-resume-eligibility.ts | 41 + ...red-agent-session-send-idempotency.test.ts | 81 + ...agent-session-settled-attach-retry.test.ts | 392 +++++ ...ructured-agent-session-subscribers.test.ts | 195 +++ .../structured-agent-session-subscribers.ts | 233 +++ ...red-agent-session-surface-lifetime.test.ts | 250 +++ ...tructured-agent-session-task-queue.test.ts | 59 + .../structured-agent-session-task-queue.ts | 25 + .../structured-agent-session-turns.ts | 290 ++++ ...t-session-wedged-profile-migration.test.ts | 334 ++++ ...tured-agent-session-wire-admission.test.ts | 160 ++ .../structured-provider-session-ownership.ts | 24 + .../structured-tui-transcript-boundary.ts | 60 + .../structured-tui-transcript-catchup.test.ts | 162 ++ .../structured-tui-transcript-catchup.ts | 266 +++ .../unhandled-provider-frame.test.ts | 253 +++ .../unhandled-provider-frame.ts | 104 ++ .../native-chat/session-file-resolver.test.ts | 114 +- src/main/native-chat/session-file-resolver.ts | 16 + src/main/plugins/plugin-host-methods.test.ts | 32 + .../plugins/plugin-host-service-bindings.ts | 17 +- .../agent-foreground-process-pi.test.ts | 2 +- .../agent-foreground-process.test.ts | 2 +- src/main/providers/pty-process-info.ts | 2 + .../providers/pty-process-list-admission.ts | 3 + ...ent-foreground-process-scan-volume.test.ts | 2 +- .../windows-foreground-process-rows.test.ts | 2 +- src/main/pty-descendant-exit-verification.ts | 78 + src/main/pty-descendant-termination.test.ts | 44 + src/main/pty-descendant-termination.ts | 12 +- ...-session-acquisition-failure-settlement.ts | 144 ++ .../agent-session-backup-recovery-fence.ts | 43 + .../agent-session-backup-recovery.test.ts | 308 ++++ .../runtime/agent-session-claim-key-state.ts | 46 + ...-session-handoff-lease-transitions.test.ts | 66 + ...agent-session-handoff-lease-transitions.ts | 187 +++ ...gent-session-handoff-record-transitions.ts | 109 ++ ...agent-session-launch-env-admission.test.ts | 94 ++ .../agent-session-lease-renewal.test.ts | 120 ++ .../runtime/agent-session-lease-renewal.ts | 38 + .../agent-session-lease-transitions.ts | 289 ++++ .../agent-session-operation-admission.ts | 33 + .../agent-session-orphan-child-reaper.test.ts | 68 + .../agent-session-orphan-child-reaper.ts | 48 + ...ent-session-process-identity-probe.test.ts | 241 +++ .../agent-session-process-identity-probe.ts | 273 ++++ .../agent-session-processless-reservation.ts | 44 + ...session-provider-handle-transition.test.ts | 48 + ...gent-session-provider-handle-transition.ts | 41 + ...gent-session-pty-write-enforcement.test.ts | 368 +++++ .../agent-session-pty-write-gate.test.ts | 277 ++++ .../runtime/agent-session-pty-write-gate.ts | 179 ++ .../agent-session-reconciliation-target.ts | 9 + .../agent-session-record-options.test.ts | 97 ++ .../runtime/agent-session-record-options.ts | 14 + .../agent-session-record-store-file.ts | 343 ++++ ...gent-session-record-store-security.test.ts | 62 + .../agent-session-record-store-security.ts | 40 + .../agent-session-record-store.test.ts | 879 ++++++++++ .../runtime/agent-session-record-store.ts | 328 ++++ ...-session-record-unsupported-schema.test.ts | 97 ++ ...ent-session-recovery-publish-fault.test.ts | 83 + .../agent-session-reservation-admission.ts | 211 +++ ...ssion-restart-handoff-adjudication.test.ts | 172 ++ ...nt-session-restart-handoff-adjudication.ts | 76 + ...agent-session-restart-lease-transitions.ts | 90 + .../agent-session-restart-reconciliation.ts | 57 + ...t-session-spawn-token-process-scan.test.ts | 25 + .../agent-session-spawn-token-process-scan.ts | 78 + ...agent-session-spawn-token-readback.test.ts | 41 + .../agent-session-spawn-token-readback.ts | 37 + .../agent-session-store-transaction-lock.ts | 27 + .../agent-session-store-transaction-queue.ts | 169 ++ ...gent-session-surface-release-transition.ts | 60 + ...-session-unreadable-record-salvage.test.ts | 168 ++ src/main/runtime/mobile-rpc-allowlist.test.ts | 6 + ...ca-runtime-agent-session-operation.test.ts | 20 + ...ctured-agent-session-create-intent.test.ts | 55 + ...runtime-structured-session-restore.test.ts | 242 +++ src/main/runtime/orca-runtime.test.ts | 4 + src/main/runtime/orca-runtime.ts | 1454 ++++++++++++++++- ...rchestration-structured-chat-lease.test.ts | 373 +++++ src/main/runtime/rpc/dispatcher.ts | 12 +- src/main/runtime/rpc/methods/ai-vault.test.ts | 3 + src/main/runtime/rpc/methods/ai-vault.ts | 28 +- src/main/runtime/rpc/methods/index.ts | 2 + .../orchestration-worker-start-receipt.ts | 11 +- ...ion-tab-agent-capability-mutations.test.ts | 146 ++ ...ession-tab-agent-status-projection.test.ts | 103 +- .../session-tab-agent-status-projection.ts | 91 +- ...on-tab-browser-placement-mutations.test.ts | 4 +- .../rpc/methods/session-tab-close-methods.ts | 28 +- .../methods/session-tab-mutation-methods.ts | 32 +- .../session-tabs-move-validation.test.ts | 159 ++ src/main/runtime/rpc/methods/session-tabs.ts | 29 +- .../methods/structured-agent-session-gate.ts | 58 + .../structured-agent-session-hold.test.ts | 235 +++ .../methods/structured-agent-session-hold.ts | 64 + .../structured-agent-session-schemas.ts | 203 +++ .../methods/structured-agent-session.test.ts | 428 +++++ .../rpc/methods/structured-agent-session.ts | 246 +++ .../methods/structured-session-tab-restore.ts | 11 + .../terminal/terminal-input-delivery.ts | 6 + .../methods/terminal/terminal-send-method.ts | 24 + .../terminal-send-agent-session-lease.test.ts | 106 ++ src/main/runtime/runtime-rpc.ts | 3 +- ...ructured-agent-session-restoration.test.ts | 88 + ...ed-structured-agent-session-restoration.ts | 43 + ...ructured-agent-session-integration.test.ts | 891 ++++++++++ ...ructured-agent-session-pty-binding.test.ts | 69 + .../structured-agent-session-runtime.test.ts | 265 +++ .../structured-agent-session-runtime.ts | 278 ++++ .../runtime/structured-tui-exit-proof.test.ts | 91 ++ src/main/runtime/structured-tui-exit-proof.ts | 44 + .../structured-tui-idle-evidence.test.ts | 27 + .../runtime/structured-tui-idle-evidence.ts | 9 + .../structured-tui-process-identity.test.ts | 98 ++ .../structured-tui-process-identity.ts | 194 +++ ...tructured-tui-recovery-claim-match.test.ts | 110 ++ .../structured-tui-recovery-claim-match.ts | 89 + src/main/ssh/ssh-remote-orca-cli.ts | 5 +- .../ssh-remote-orchestration-post-output.ts | 3 +- .../startup/desktop-startup-ordering.test.ts | 14 + src/main/startup/hydrate-shell-path.ts | 13 +- .../legacy-worker-renderer-recovery.test.ts | 75 +- .../legacy-worker-renderer-recovery.ts | 15 +- .../startup/login-shell-environment.test.ts | 86 + src/main/startup/login-shell-environment.ts | 160 ++ .../source-control-agent-launch.ts | 8 +- src/main/windows-pty-root-identity.test.ts | 2 +- .../windows/windows-process-table.test.ts | 37 +- src/main/windows/windows-process-table.ts | 31 +- src/preload/api/app-api.ts | 2 + src/preload/api/runtime-api.ts | 4 + src/preload/index.ts | 42 +- .../src/app-shell/AppBackgroundServices.tsx | 2 + .../src/app-shell/use-app-shell-services.ts | 2 + .../src/app-shell/use-app-startup-actions.ts | 39 + .../app-shell/use-app-startup-hydration.ts | 49 +- src/renderer/src/app-startup-routing.test.ts | 54 + src/renderer/src/assets/main.css | 6 + src/renderer/src/components/Terminal.tsx | 48 +- .../src/components/WorktreeJumpPalette.tsx | 5 +- .../NativeChatAutocompleteMenus.test.tsx | 18 + .../native-chat/NativeChatComposer.test.tsx | 84 +- .../native-chat/NativeChatComposer.tsx | 190 +-- .../NativeChatComposerActions.test.tsx | 57 +- .../native-chat/NativeChatComposerActions.tsx | 21 +- .../native-chat/NativeChatComposerField.tsx | 6 +- ...iveChatMessageList.provider-frame.test.tsx | 56 + .../native-chat/NativeChatMessageList.tsx | 43 +- ...tiveChatOrchestrationPausedNotice.test.tsx | 33 + .../NativeChatOrchestrationPausedNotice.tsx | 42 + .../NativeChatQuestionCard.test.tsx | 22 +- .../native-chat/NativeChatQuestionCard.tsx | 50 +- .../NativeChatSessionOptionPickers.test.tsx | 47 +- .../NativeChatSessionOptionPickers.tsx | 19 +- .../NativeChatStructuredSession.test.tsx | 204 +++ .../NativeChatStructuredSession.tsx | 239 +++ .../native-chat/NativeChatToolRun.test.tsx | 65 + .../components/native-chat/NativeChatView.tsx | 47 +- ...turedAgentSessionPaneOverlayLayer.test.tsx | 205 +++ ...StructuredAgentSessionPaneOverlayLayer.tsx | 139 ++ ...tructuredAgentSessionStatusBridge.test.tsx | 238 +++ .../StructuredAgentSessionStatusBridge.tsx | 119 ++ .../native-chat-attachment-upload.test.ts | 23 + .../native-chat-attachment-upload.ts | 19 +- .../native-chat/native-chat-composer-types.ts | 24 + .../native-chat/native-chat-diff.test.ts | 33 + .../native-chat/native-chat-file-link.test.ts | 29 + .../native-chat/native-chat-file-link.ts | 20 +- .../native-chat-leaf-routing.test.ts | 27 + .../native-chat/native-chat-leaf-routing.ts | 17 +- .../native-chat-skill-discovery-context.ts | 17 +- .../native-chat-stop-layering.test.ts | 37 + ...ative-chat-structured-composer-dispatch.ts | 14 + .../native-chat-typing-indicator.test.ts | 138 ++ .../native-chat-typing-indicator.ts | 54 + .../native-chat/native-chat-view-types.ts | 26 +- ...d-agent-session-message-projection.test.ts | 105 ++ ...ctured-agent-session-message-projection.ts | 38 + .../structured-agent-session-read-owner.ts | 272 +++ ...tured-agent-session-read-transport.test.ts | 135 ++ ...structured-agent-session-read-transport.ts | 210 +++ ...-native-chat-composer-attachments.test.tsx | 23 +- .../use-native-chat-composer-attachments.ts | 16 +- .../use-native-chat-composer-paste.test.tsx | 20 + .../use-native-chat-composer-paste.ts | 5 + .../use-native-chat-context-menu.tsx | 22 +- ...-native-chat-external-attachments.test.tsx | 16 + .../use-native-chat-external-attachments.ts | 17 +- .../use-native-chat-file-link-context.ts | 7 + .../use-native-chat-pty-composer-send.ts | 112 ++ .../use-native-chat-skills.react.test.tsx | 41 + .../use-native-chat-skills.test.ts | 17 + .../native-chat/use-native-chat-skills.ts | 17 +- .../use-native-chat-status-entry.ts | 20 + ...use-structured-agent-session-hold.test.tsx | 124 ++ .../use-structured-agent-session-hold.ts | 59 + ...e-structured-agent-session-outbox.test.tsx | 454 +++++ .../use-structured-agent-session-outbox.ts | 317 ++++ ...use-structured-agent-session-read.test.tsx | 430 +++++ .../use-structured-agent-session-read.ts | 59 + .../use-structured-agent-session.test.tsx | 299 ++++ .../use-structured-agent-session.ts | 254 +++ .../right-sidebar/AiVaultSessionRow.tsx | 1 + .../ai-vault-session-launch-actions.ts | 17 +- .../settings/ExperimentalPane.test.tsx | 128 ++ .../NativeChatExperimentalSetting.tsx | 48 +- .../WorktreeCardAgents.activation.test.tsx | 42 + .../components/sidebar/WorktreeCardAgents.tsx | 3 +- .../worktree-agent-live-index-patch.ts | 1 + .../worktree-agent-row-selectors.test.ts | 39 + .../sidebar/worktree-agent-row-selectors.ts | 30 +- .../src/components/tab-bar/SortableTab.tsx | 14 +- .../tab-bar/SortableTabContextMenu.test.tsx | 7 + .../tab-bar/SortableTabContextMenu.tsx | 46 +- .../tab-bar/group-tab-order.test.ts | 37 + .../src/components/tab-bar/group-tab-order.ts | 47 +- .../src/components/tab-bar/reconcile-order.ts | 19 +- .../components/tab-bar/tab-bar-item-model.ts | 30 +- .../tab-bar/tab-bar-item-surface.tsx | 87 +- .../src/components/tab-bar/tab-bar-props.ts | 4 +- .../use-tab-bar-create-menu-controller.ts | 9 +- .../tab-bar/use-tab-bar-item-projection.ts | 13 + .../tab-group/AiVaultSessionDropLayer.tsx | 14 + .../components/tab-group/TabGroupPanel.tsx | 39 +- .../src/components/tab-group/tab-drag-data.ts | 2 +- .../useTabGroupActivationCommands.ts | 16 +- .../tab-group/useTabGroupItemProjections.ts | 20 +- ...abCloseCommands.structured-session.test.ts | 142 ++ .../tab-group/useTabGroupTabCloseCommands.ts | 59 +- .../useTabGroupWorkspaceModel.focus.test.ts | 66 + .../tab-group/useTabGroupWorkspaceModel.ts | 23 +- .../TerminalContextMenu.test.tsx | 9 +- .../terminal-pane/TerminalContextMenu.tsx | 27 +- .../components/terminal-pane/TerminalPane.tsx | 207 +-- .../TerminalPaneHeaderOverlay.tsx | 60 +- .../TerminalPaneOverlayLayer.tsx | 3 - .../pty-connection-fresh-spawn-guards.test.ts | 28 + .../terminal-pane/pty-connection-types.ts | 2 + .../pane-pty-visibility-bind.ts | 5 + .../terminal-pane-recovery.test.ts | 20 +- .../terminal-pane/terminal-pane-recovery.ts | 6 + .../terminal-tab-agent-type-index.test.ts | 26 + .../terminal-tab-agent-type-index.ts | 33 +- .../use-terminal-pane-lifecycle.ts | 4 + .../structured-terminal-session-disposal.ts | 64 + .../src/components/terminal/tab-type-cycle.ts | 4 +- .../terminal-tab-actions-kill-all.test.ts | 1 + ...nal-tab-actions-structured-session.test.ts | 133 ++ ...terminal-tab-actions-unified-close.test.ts | 254 +++ .../terminal/terminal-tab-actions.test.ts | 9 +- .../terminal/terminal-tab-actions.ts | 139 +- .../terminal-tab-bulk-actions.test.ts | 139 ++ .../terminal/terminal-tab-bulk-actions.ts | 73 +- .../src/hooks/ipc-events-test-harness.ts | 18 +- .../terminal-ui-routing-ipc-bridge.ts | 9 +- src/renderer/src/hooks/ipc-tab-switch.ts | 5 + .../src/hooks/modal-return-focus-action.ts | 3 +- src/renderer/src/hooks/resolve-zoom-target.ts | 3 +- ...tructured-session-completion-focus.test.ts | 60 + .../i18n/locale-english-regression.test.ts | 27 + src/renderer/src/i18n/locales/en.json | 33 +- src/renderer/src/i18n/locales/ko.json | 32 +- src/renderer/src/i18n/locales/zh.json | 32 +- ...tivate-ai-vault-structured-session.test.ts | 36 + .../activate-ai-vault-structured-session.ts | 95 ++ .../lib/agent-launch-prompt-delivery.test.ts | 17 + .../src/lib/agent-launch-prompt-delivery.ts | 18 +- src/renderer/src/lib/ai-vault-session-drag.ts | 14 + ...i-vault-session-resume-preparation.test.ts | 2 + .../ai-vault-session-resume-preparation.ts | 3 +- .../src/lib/browser-palette-page-entries.ts | 4 +- .../src/lib/launch-agent-in-new-tab.test.ts | 45 +- .../src/lib/launch-agent-in-new-tab.ts | 25 + ...launch-agent-structured-chat-guard.test.ts | 321 ++++ .../launch-structured-codex-session.test.ts | 84 + .../lib/launch-structured-codex-session.ts | 87 + ...sume-sleeping-agent-session-replay.test.ts | 42 + .../src/lib/resume-sleeping-agent-session.ts | 4 + ...ume-stale-structured-agent-session.test.ts | 41 + .../src/lib/simulator-palette-search.ts | 4 +- ...ent-session-launch-windows-quoting.test.ts | 8 +- .../src/lib/sleeping-agent-session-launch.ts | 44 +- .../structured-agent-session-launch.test.ts | 216 +++ .../lib/structured-agent-session-launch.ts | 136 ++ ...tured-agent-session-tab-activation.test.ts | 89 + ...structured-agent-session-tab-activation.ts | 43 + ...uctured-agent-synthetic-sleeping-record.ts | 15 + ...tructured-native-chat-availability.test.ts | 203 +++ .../structured-native-chat-availability.ts | 30 + .../src/lib/workspace-tab-palette-search.ts | 4 +- src/renderer/src/lib/worktree-activation.ts | 99 +- .../worktree-agent-activation-gate.test.ts | 454 +++++ .../src/lib/worktree-agent-activation-gate.ts | 278 ++++ .../worktree-agent-activation-seam.test.ts | 204 +++ .../worktree-agent-live-surface-adoption.ts | 65 + .../worktree-agent-structured-inventory.ts | 78 + .../lib/worktree-creation-agent-seeds.test.ts | 58 +- ...ivation-preserved-pane-replacement.test.ts | 23 +- ...tion-runtime-owned-resume-deferral.test.ts | 13 +- ...worktree-reactivation-tab-forkbomb.test.ts | 85 +- .../host-session-mirror-settle-census.test.ts | 7 +- ...-session-empty-worktree-visibility.test.ts | 209 +++ ...ctured-session-tabs-host-isolation.test.ts | 219 +++ ...local-structured-session-tabs-sync.test.ts | 458 ++++++ .../local-structured-session-tabs-sync.ts | 184 +++ .../structured-agent-session-client.test.ts | 46 + .../structured-agent-session-client.ts | 41 + .../structured-agent-session-close.test.ts | 58 + .../runtime/structured-agent-session-close.ts | 23 + .../web-session-client-owned-tab-placement.ts | 9 +- .../src/runtime/web-session-focus-intent.ts | 5 + .../runtime/web-session-intent-owner.test.ts | 11 + .../web-session-structured-tab-focus.test.ts | 110 ++ .../src/runtime/web-session-tabs-sync.test.ts | 86 + .../src/runtime/web-session-tabs-sync.ts | 284 +++- .../src/startup/startup-degraded-recovery.ts | 5 +- src/renderer/src/store/slices/agent-status.ts | 12 +- .../slices/tabs-model-reconciliation.test.ts | 77 + src/renderer/src/store/slices/tabs.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 6 + .../src/store/terminals/terminal-actions.ts | 13 +- .../terminals/terminal-startup-queues.ts | 9 +- .../src/store/terminals/terminal-state.ts | 2 + .../store/terminals/terminal-tab-creation.ts | 41 +- .../src/web/preload-api/web-app-api.ts | 1 + .../src/web/preload-api/web-runtime-api.ts | 16 + src/shared/agent-session-journal-item-key.ts | 234 +++ .../agent-session-journal-schemas.test.ts | 191 +++ src/shared/agent-session-journal-schemas.ts | 168 ++ src/shared/agent-session-journal-types.ts | 216 +++ .../agent-session-lease-adjudication.test.ts | 338 ++++ .../agent-session-lease-adjudication.ts | 261 +++ .../agent-session-mutation-envelope.test.ts | 158 ++ src/shared/agent-session-mutation-envelope.ts | 157 ++ src/shared/agent-session-next-fence.ts | 17 + .../agent-session-operation-ledger.test.ts | 174 ++ src/shared/agent-session-operation-ledger.ts | 195 +++ .../agent-session-provider-handle.test.ts | 302 ++++ src/shared/agent-session-provider-handle.ts | 216 +++ .../agent-session-pty-write-admission.test.ts | 247 +++ .../agent-session-pty-write-admission.ts | 177 ++ .../agent-session-pty-write-refusal-copy.ts | 13 + .../agent-session-record.test-fixture.ts | 67 + src/shared/agent-session-record.ts | 357 ++++ src/shared/agent-session-refusal-retry.ts | 39 + src/shared/agent-session-wire.ts | 258 +++ src/shared/agent-status-types.ts | 2 + src/shared/ai-vault-resume-preparation.ts | 3 +- src/shared/ai-vault-types.ts | 5 + .../child-process-import-allowlist.txt | 3 + .../cancel-process-acquisition.ts | 27 + .../close-process-registry.test.ts | 46 + .../child-process/close-process-registry.ts | 26 + .../retryable-process-exit-proof.test.ts | 37 + .../retryable-process-exit-proof.ts | 26 + src/shared/child-process/run-process.ts | 9 +- src/shared/default-global-settings.ts | 1 + src/shared/global-settings-types.ts | 2 + src/shared/native-chat-diff.ts | 33 + .../native-chat-provider-frame-summary.ts | 11 + .../native-chat-session-option-snapshot.ts | 25 +- src/shared/native-chat-tool-summary.ts | 19 +- src/shared/native-chat-types.ts | 11 + .../pane-agent-identity-inventory.test.ts | 1 - src/shared/protocol-version.ts | 11 + .../runtime-mobile-session-tab-contracts.ts | 116 ++ src/shared/runtime-session-contracts.ts | 116 +- src/shared/runtime-terminal-contracts.ts | 8 + src/shared/runtime-types.ts | 1 + src/shared/sha256.ts | 80 + .../structured-agent-session-coalescer.ts | 81 + .../structured-agent-session-composer.ts | 103 ++ .../structured-agent-session-mutation.test.ts | 33 + .../structured-agent-session-mutation.ts | 39 + .../structured-agent-session-options.test.ts | 109 ++ .../structured-agent-session-options.ts | 146 ++ src/shared/structured-agent-session-outbox.ts | 174 ++ ...tructured-agent-session-projection.test.ts | 84 + .../structured-agent-session-projection.ts | 154 ++ .../structured-agent-session-reducer.test.ts | 253 +++ .../structured-agent-session-reducer.ts | 188 +++ src/shared/tab-types.ts | 20 +- src/shared/telemetry-events.test.ts | 9 + src/shared/telemetry-events.ts | 1 + src/shared/tui-agent-resume-startup.ts | 71 + src/shared/tui-agent-startup-hermes.test.ts | 230 +++ .../tui-agent-startup-script.test-fixture.ts | 24 + .../tui-agent-startup-session-options.test.ts | 15 +- src/shared/tui-agent-startup.test.ts | 241 +-- src/shared/tui-agent-startup.ts | 58 +- src/shared/workspace-session-schema.test.ts | 39 + src/shared/workspace-session-schema.ts | 20 +- .../workspace-session-tab-type-schema.ts | 20 + ...ss-version-agent-session-wire.unit.test.ts | 667 ++++++++ ...oss-version-browser-placement.unit.test.ts | 21 +- .../release-checkout-tree.ts | 139 ++ .../cross-version-wire/release-checkout.ts | 282 ++-- .../release-checkout.unit.test.ts | 510 ++++++ .../versioned-agent-session-wire.ts | 155 ++ .../versioned-terminal-wire.ts | 26 +- tests/e2e/helpers/electron-launch-args.ts | 7 +- .../helpers/electron-launch-args.unit.test.ts | 6 +- 615 files changed, 64512 insertions(+), 1646 deletions(-) create mode 100644 config/scripts/windows-process-tree-gyp-rebuild.mjs create mode 100644 config/scripts/windows-process-tree-gyp-rebuild.test.mjs create mode 100644 config/scripts/windows-process-tree-patch-contract.test.mjs create mode 100644 src/main/ai-vault/structured-session-ownership.test.ts create mode 100644 src/main/ai-vault/structured-session-ownership.ts create mode 100644 src/main/claude/claude-structured-owner-identity.ts create mode 100644 src/main/claude/claude-transcript-branch-proof.ts create mode 100644 src/main/codex/codex-app-server-connection-types.ts create mode 100644 src/main/codex/codex-app-server-connection.test.ts create mode 100644 src/main/codex/codex-app-server-connection.ts create mode 100644 src/main/codex/codex-app-server-exit-error.ts create mode 100644 src/main/codex/codex-app-server-handshake-exit-proof.ts create mode 100644 src/main/codex/codex-app-server-handshake.ts create mode 100644 src/main/codex/codex-app-server-jsonl.ts create mode 100644 src/main/codex/codex-app-server-notification-schema.ts create mode 100644 src/main/codex/codex-app-server-posix-supervisor.test.ts create mode 100644 src/main/codex/codex-app-server-posix-supervisor.ts create mode 100644 src/main/codex/codex-app-server-process-teardown.test.ts create mode 100644 src/main/codex/codex-app-server-process-teardown.ts create mode 100644 src/main/codex/codex-app-server-request-error.ts create mode 100644 src/main/codex/codex-app-server-teardown.integration.test.ts create mode 100644 src/main/codex/codex-resume-process-proof.test.ts create mode 100644 src/main/codex/codex-resume-process-proof.ts create mode 100644 src/main/codex/codex-server-request-disposition.test.ts create mode 100644 src/main/codex/codex-server-request-disposition.ts create mode 100644 src/main/codex/codex-structured-acquisition-exit-proof.test.ts create mode 100644 src/main/codex/codex-structured-acquisition-lifecycle.ts create mode 100644 src/main/codex/codex-structured-acquisition-window.ts create mode 100644 src/main/codex/codex-structured-app-server-args.test.ts create mode 100644 src/main/codex/codex-structured-app-server-args.ts create mode 100644 src/main/codex/codex-structured-child-environment.test.ts create mode 100644 src/main/codex/codex-structured-child-environment.ts create mode 100644 src/main/codex/codex-structured-item-streams.ts create mode 100644 src/main/codex/codex-structured-item-translation.test.ts create mode 100644 src/main/codex/codex-structured-item-translation.ts create mode 100644 src/main/codex/codex-structured-journal-translation.test.ts create mode 100644 src/main/codex/codex-structured-journal-translation.ts create mode 100644 src/main/codex/codex-structured-launch-resolution.test.ts create mode 100644 src/main/codex/codex-structured-launch-resolution.ts create mode 100644 src/main/codex/codex-structured-location-support.ts create mode 100644 src/main/codex/codex-structured-owner-identity.test.ts create mode 100644 src/main/codex/codex-structured-owner-identity.ts create mode 100644 src/main/codex/codex-structured-prompt-items.test.ts create mode 100644 src/main/codex/codex-structured-prompt-items.ts create mode 100644 src/main/codex/codex-structured-prompt-replies.test.ts create mode 100644 src/main/codex/codex-structured-prompt-replies.ts create mode 100644 src/main/codex/codex-structured-provider-events.ts create mode 100644 src/main/codex/codex-structured-session-adapter.test.ts create mode 100644 src/main/codex/codex-structured-session-adapter.ts create mode 100644 src/main/codex/codex-structured-session-cancel.test.ts create mode 100644 src/main/codex/codex-structured-session-close.ts create mode 100644 src/main/codex/codex-structured-session-options.test.ts create mode 100644 src/main/codex/codex-structured-session-options.ts create mode 100644 src/main/codex/codex-structured-session-shutdown.test.ts create mode 100644 src/main/codex/codex-structured-session-state.ts create mode 100644 src/main/codex/codex-structured-thread-facts.ts create mode 100644 src/main/codex/codex-structured-thread-open.ts create mode 100644 src/main/codex/codex-structured-turn-cancellation.ts create mode 100644 src/main/codex/codex-structured-turn-processes.integration.test.ts create mode 100644 src/main/codex/codex-structured-turn-processes.ts create mode 100644 src/main/codex/codex-structured-turn-start.ts create mode 100644 src/main/codex/codex-tui-resume-real-binary.integration.test.ts create mode 100644 src/main/codex/codex-tui-rollout-proof.test.ts create mode 100644 src/main/codex/codex-tui-rollout-proof.ts create mode 100644 src/main/ipc/desktop-runtime-sender-lifecycle.ts create mode 100644 src/main/ipc/pty-agent-session-write-gate.test.ts create mode 100644 src/main/ipc/pty/agent-session-write-refusal-report.ts create mode 100644 src/main/ipc/runtime-subscribe-lifecycle.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-blob-store.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-compaction.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-cursor.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-cursor.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-item-identity.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-legacy-identity.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-legacy-import.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-log-file.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-log-file.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-open.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-paths.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-payload-bounds.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-reducer.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-reducer.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-builders.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-schema.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-schema.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-store-contracts.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-store.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-store.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-write-guards.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-history-page.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts create mode 100644 src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts create mode 100644 src/main/native-chat/agent-session-wire/provider-frame-disposition.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts create mode 100644 src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts create mode 100644 src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts create mode 100644 src/main/pty-descendant-exit-verification.ts create mode 100644 src/main/runtime/agent-session-acquisition-failure-settlement.ts create mode 100644 src/main/runtime/agent-session-backup-recovery-fence.ts create mode 100644 src/main/runtime/agent-session-backup-recovery.test.ts create mode 100644 src/main/runtime/agent-session-claim-key-state.ts create mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.test.ts create mode 100644 src/main/runtime/agent-session-handoff-lease-transitions.ts create mode 100644 src/main/runtime/agent-session-handoff-record-transitions.ts create mode 100644 src/main/runtime/agent-session-launch-env-admission.test.ts create mode 100644 src/main/runtime/agent-session-lease-renewal.test.ts create mode 100644 src/main/runtime/agent-session-lease-renewal.ts create mode 100644 src/main/runtime/agent-session-lease-transitions.ts create mode 100644 src/main/runtime/agent-session-operation-admission.ts create mode 100644 src/main/runtime/agent-session-orphan-child-reaper.test.ts create mode 100644 src/main/runtime/agent-session-orphan-child-reaper.ts create mode 100644 src/main/runtime/agent-session-process-identity-probe.test.ts create mode 100644 src/main/runtime/agent-session-process-identity-probe.ts create mode 100644 src/main/runtime/agent-session-processless-reservation.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.test.ts create mode 100644 src/main/runtime/agent-session-provider-handle-transition.ts create mode 100644 src/main/runtime/agent-session-pty-write-enforcement.test.ts create mode 100644 src/main/runtime/agent-session-pty-write-gate.test.ts create mode 100644 src/main/runtime/agent-session-pty-write-gate.ts create mode 100644 src/main/runtime/agent-session-reconciliation-target.ts create mode 100644 src/main/runtime/agent-session-record-options.test.ts create mode 100644 src/main/runtime/agent-session-record-options.ts create mode 100644 src/main/runtime/agent-session-record-store-file.ts create mode 100644 src/main/runtime/agent-session-record-store-security.test.ts create mode 100644 src/main/runtime/agent-session-record-store-security.ts create mode 100644 src/main/runtime/agent-session-record-store.test.ts create mode 100644 src/main/runtime/agent-session-record-store.ts create mode 100644 src/main/runtime/agent-session-record-unsupported-schema.test.ts create mode 100644 src/main/runtime/agent-session-recovery-publish-fault.test.ts create mode 100644 src/main/runtime/agent-session-reservation-admission.ts create mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.test.ts create mode 100644 src/main/runtime/agent-session-restart-handoff-adjudication.ts create mode 100644 src/main/runtime/agent-session-restart-lease-transitions.ts create mode 100644 src/main/runtime/agent-session-restart-reconciliation.ts create mode 100644 src/main/runtime/agent-session-spawn-token-process-scan.test.ts create mode 100644 src/main/runtime/agent-session-spawn-token-process-scan.ts create mode 100644 src/main/runtime/agent-session-spawn-token-readback.test.ts create mode 100644 src/main/runtime/agent-session-spawn-token-readback.ts create mode 100644 src/main/runtime/agent-session-store-transaction-lock.ts create mode 100644 src/main/runtime/agent-session-store-transaction-queue.ts create mode 100644 src/main/runtime/agent-session-surface-release-transition.ts create mode 100644 src/main/runtime/agent-session-unreadable-record-salvage.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts create mode 100644 src/main/runtime/orca-runtime-structured-session-restore.test.ts create mode 100644 src/main/runtime/orchestration-structured-chat-lease.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-gate.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-hold.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-schemas.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session.ts create mode 100644 src/main/runtime/rpc/methods/structured-session-tab-restore.ts create mode 100644 src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts create mode 100644 src/main/runtime/saved-structured-agent-session-restoration.test.ts create mode 100644 src/main/runtime/saved-structured-agent-session-restoration.ts create mode 100644 src/main/runtime/structured-agent-session-integration.test.ts create mode 100644 src/main/runtime/structured-agent-session-pty-binding.test.ts create mode 100644 src/main/runtime/structured-agent-session-runtime.test.ts create mode 100644 src/main/runtime/structured-agent-session-runtime.ts create mode 100644 src/main/runtime/structured-tui-exit-proof.test.ts create mode 100644 src/main/runtime/structured-tui-exit-proof.ts create mode 100644 src/main/runtime/structured-tui-idle-evidence.test.ts create mode 100644 src/main/runtime/structured-tui-idle-evidence.ts create mode 100644 src/main/runtime/structured-tui-process-identity.test.ts create mode 100644 src/main/runtime/structured-tui-process-identity.ts create mode 100644 src/main/runtime/structured-tui-recovery-claim-match.test.ts create mode 100644 src/main/runtime/structured-tui-recovery-claim-match.ts create mode 100644 src/main/startup/login-shell-environment.test.ts create mode 100644 src/main/startup/login-shell-environment.ts create mode 100644 src/renderer/src/app-shell/use-app-startup-actions.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-structured-composer-dispatch.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-typing-indicator.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-status-entry.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-read.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts create mode 100644 src/renderer/src/components/terminal/structured-terminal-session-disposal.ts create mode 100644 src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts create mode 100644 src/renderer/src/hooks/structured-session-completion-focus.test.ts create mode 100644 src/renderer/src/lib/activate-ai-vault-structured-session.test.ts create mode 100644 src/renderer/src/lib/activate-ai-vault-structured-session.ts create mode 100644 src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts create mode 100644 src/renderer/src/lib/launch-structured-codex-session.test.ts create mode 100644 src/renderer/src/lib/launch-structured-codex-session.ts create mode 100644 src/renderer/src/lib/resume-stale-structured-agent-session.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch.ts create mode 100644 src/renderer/src/lib/structured-agent-session-tab-activation.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-tab-activation.ts create mode 100644 src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts create mode 100644 src/renderer/src/lib/structured-native-chat-availability.test.ts create mode 100644 src/renderer/src/lib/structured-native-chat-availability.ts create mode 100644 src/renderer/src/lib/worktree-agent-activation-gate.test.ts create mode 100644 src/renderer/src/lib/worktree-agent-activation-gate.ts create mode 100644 src/renderer/src/lib/worktree-agent-activation-seam.test.ts create mode 100644 src/renderer/src/lib/worktree-agent-live-surface-adoption.ts create mode 100644 src/renderer/src/lib/worktree-agent-structured-inventory.ts create mode 100644 src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-client.test.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-client.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-close.test.ts create mode 100644 src/renderer/src/runtime/structured-agent-session-close.ts create mode 100644 src/renderer/src/runtime/web-session-structured-tab-focus.test.ts create mode 100644 src/shared/agent-session-journal-item-key.ts create mode 100644 src/shared/agent-session-journal-schemas.test.ts create mode 100644 src/shared/agent-session-journal-schemas.ts create mode 100644 src/shared/agent-session-journal-types.ts create mode 100644 src/shared/agent-session-lease-adjudication.test.ts create mode 100644 src/shared/agent-session-lease-adjudication.ts create mode 100644 src/shared/agent-session-mutation-envelope.test.ts create mode 100644 src/shared/agent-session-mutation-envelope.ts create mode 100644 src/shared/agent-session-next-fence.ts create mode 100644 src/shared/agent-session-operation-ledger.test.ts create mode 100644 src/shared/agent-session-operation-ledger.ts create mode 100644 src/shared/agent-session-provider-handle.test.ts create mode 100644 src/shared/agent-session-provider-handle.ts create mode 100644 src/shared/agent-session-pty-write-admission.test.ts create mode 100644 src/shared/agent-session-pty-write-admission.ts create mode 100644 src/shared/agent-session-pty-write-refusal-copy.ts create mode 100644 src/shared/agent-session-record.test-fixture.ts create mode 100644 src/shared/agent-session-record.ts create mode 100644 src/shared/agent-session-refusal-retry.ts create mode 100644 src/shared/agent-session-wire.ts create mode 100644 src/shared/child-process/cancel-process-acquisition.ts create mode 100644 src/shared/child-process/close-process-registry.test.ts create mode 100644 src/shared/child-process/close-process-registry.ts create mode 100644 src/shared/child-process/retryable-process-exit-proof.test.ts create mode 100644 src/shared/child-process/retryable-process-exit-proof.ts create mode 100644 src/shared/native-chat-provider-frame-summary.ts create mode 100644 src/shared/runtime-mobile-session-tab-contracts.ts create mode 100644 src/shared/sha256.ts create mode 100644 src/shared/structured-agent-session-coalescer.ts create mode 100644 src/shared/structured-agent-session-composer.ts create mode 100644 src/shared/structured-agent-session-mutation.test.ts create mode 100644 src/shared/structured-agent-session-mutation.ts create mode 100644 src/shared/structured-agent-session-options.test.ts create mode 100644 src/shared/structured-agent-session-options.ts create mode 100644 src/shared/structured-agent-session-outbox.ts create mode 100644 src/shared/structured-agent-session-projection.test.ts create mode 100644 src/shared/structured-agent-session-projection.ts create mode 100644 src/shared/structured-agent-session-reducer.test.ts create mode 100644 src/shared/structured-agent-session-reducer.ts create mode 100644 src/shared/tui-agent-resume-startup.ts create mode 100644 src/shared/tui-agent-startup-hermes.test.ts create mode 100644 src/shared/tui-agent-startup-script.test-fixture.ts create mode 100644 src/shared/workspace-session-tab-type-schema.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/release-checkout-tree.ts create mode 100644 tests/e2e/cross-version-wire/release-checkout.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/versioned-agent-session-wire.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 338bcda7dbd..f8589081a24 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -539,8 +539,10 @@ jobs: - name: Old/new client and server compatibility journeys run: >- pnpm exec vitest run --config config/vitest.config.ts + tests/e2e/cross-version-wire/release-checkout.unit.test.ts tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts + tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index bf7d5b1382c..a6000d02297 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -140,6 +140,9 @@ module.exports = { // it is gitignored, but exclude it defensively so a stray local capture at // package time never bloats app.asar. '!pr-evidence{,/**/*}', + // Why: local agent/tooling directories may contain worktree symlink loops; + // they are never runtime inputs and must not be traversed by electron-builder. + '!{.claude,.grok,.agents,.codex}{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 88dccc5746d..ef6b02767b8 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -22,6 +22,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'jsonc-parser', 'node-pty', 'posthog-node', + 'proper-lockfile', // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. 'serve-sim', 'qrcode', diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index 3e43beb359e..364335e537c 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -32,9 +32,12 @@ import { import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' +import { + nodeGypRebuildInvocation, + WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR +} from './windows-process-tree-gyp-rebuild.mjs' const ROOT = resolve(import.meta.dirname, '..', '..') -const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree') const SUPPORTED_ARCHES = ['x64', 'arm64'] /** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */ @@ -169,12 +172,9 @@ function main() { applyWindowsProcessTreeBuildFixes() assertPatchApplied() - console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`) - execFileSync( - process.execPath, - [join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}`], - { cwd: PACKAGE_DIR, stdio: 'inherit' } - ) + const gyp = nodeGypRebuildInvocation(arch) + console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`) + execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' }) const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node') if (!existsSync(built)) { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index bf28401fd35..0a42eea5067 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -44,6 +44,7 @@ describe('electron-builder config', () => { '!tests{,/**/*}', '!examples{,/**/*}', '!pr-evidence{,/**/*}', + '!{.claude,.grok,.agents,.codex}{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', @@ -52,6 +53,23 @@ describe('electron-builder config', () => { ) }) + it('keeps local agent tooling out of app.asar', () => { + const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files) + matcher.prependPattern('**/*') + const isPacked = matcher.createFilter() + const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false }) + + for (const toolingPath of [ + '.grok/skills/review-and-submit/review-and-submit/SKILL.md', + '.claude/skills/review-and-submit/review-and-submit/SKILL.md', + '.agents/skills/electron/SKILL.md', + '.codex/sessions/session.json' + ]) { + expect(packs(toolingPath)).toBe(false) + } + expect(packs('out/main/index.js')).toBe(true) + }) + // Why: `files` is an all-negation list, so electron-builder's default `**/*` packs // anything without an explicit `!` entry — examples/ landed without one and shipped // hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar. @@ -436,7 +454,7 @@ describe('electron-builder config', () => { } }) - it('includes @parcel/watcher in the packaged runtime closure', () => { + it('includes external main dependencies in the packaged runtime closure', () => { // Why: the main process imports '@parcel/watcher' for filesystem change // events; if it is absent from the packaged closure the serve host silently // stops propagating file changes to clients (regression guard for #4851). @@ -448,6 +466,7 @@ describe('electron-builder config', () => { target.startsWith(join('node_modules', '@parcel', 'watcher-')) ) ).toBe(true) + expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) }) it('prunes non-target @parcel/watcher architecture subpackages', async () => { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c51b1c18a5c..3ea588aaf8f 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -93,8 +93,18 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', 'src/shared/browser-client-host-placement', + 'src/shared/agent-session-wire', + 'src/shared/agent-session-mutation-envelope', + 'src/shared/agent-session-journal-', + 'src/main/ai-vault/structured-session-ownership.ts', + 'src/main/native-chat/agent-session-journal/', + 'src/main/native-chat/agent-session-wire/', + 'src/main/runtime/agent-session-record-store', 'src/main/runtime/rpc/dispatcher', + 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', + 'src/main/runtime/rpc/methods/session-tabs.ts', + 'src/main/runtime/rpc/methods/structured-agent-session', 'src/main/runtime/rpc/methods/terminal', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ea83796e6cb..3f35dae1dd9 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -181,8 +181,24 @@ describe('per-job path classification', () => { for (const file of [ 'src/shared/protocol-version.ts', 'src/shared/terminal-stream-protocol.ts', + 'src/shared/agent-session-wire.ts', + 'src/shared/agent-session-mutation-envelope.ts', + 'src/shared/agent-session-journal-item-key.ts', + 'src/shared/agent-session-journal-types.ts', + 'src/main/ai-vault/structured-session-ownership.ts', + 'src/main/native-chat/agent-session-journal/journal-cursor.ts', + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/main/native-chat/agent-session-journal/journal-row-schema.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-host.ts', + 'src/main/runtime/agent-session-record-store.ts', 'src/main/runtime/rpc/dispatcher.ts', + 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts', + 'src/main/runtime/rpc/methods/session-tabs.ts', + 'src/main/runtime/rpc/methods/structured-agent-session.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-gate.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-hold.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts', 'src/main/runtime/rpc/methods/terminal.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs new file mode 100644 index 00000000000..06ffcb3dfb1 --- /dev/null +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -0,0 +1,33 @@ +/** + * Where and how `@vscode/windows-process-tree` is rebuilt from source. + * + * node-gyp must run from the package's physical directory, never the + * `node_modules` symlink/junction pnpm installs there: gyp expands the + * node-addon-api dependency by probing node (whose cwd resolves to the + * physical path), gets back a store-relative `../../../../node-addon-api@…` + * hop, then resolves that hop against the rebuild cwd. From the link path the + * hop escapes the store and configure fails with "node_addon_api.gyp not + * found" (run 32999886072). + */ +import { realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const ROOT = resolve(import.meta.dirname, '..', '..') + +export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join( + ROOT, + 'node_modules', + '@vscode', + 'windows-process-tree' +) + +export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { + return { + args: [ + join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), + 'rebuild', + `--arch=${arch}` + ], + cwd: realpathSync(packageDir) + } +} diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs new file mode 100644 index 00000000000..a90a92f42bf --- /dev/null +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + nodeGypRebuildInvocation, + WINDOWS_PROCESS_TREE_PACKAGE_DIR +} from './windows-process-tree-gyp-rebuild.mjs' + +describe('windows-process-tree node-gyp rebuild', () => { + it("resolves node-addon-api's gyp target from the rebuild cwd", () => { + // gyp probes node-addon-api with the package's physical directory as cwd, + // so the emitted target is store-relative; gyp then resolves that hop + // against the rebuild cwd. Rebuilding from pnpm's node_modules link sends + // the hop outside the store and configure fails (run 32999886072). + const { cwd } = nodeGypRebuildInvocation('x64') + const targets = execFileSync(process.execPath, ['-p', "require('node-addon-api').targets"], { + cwd: realpathSync(WINDOWS_PROCESS_TREE_PACKAGE_DIR), + encoding: 'utf8' + }).trim() + expect(existsSync(resolve(cwd, targets))).toBe(true) + }) + + it('forwards the requested arch to node-gyp', () => { + const { args } = nodeGypRebuildInvocation('arm64') + expect(args).toContain('rebuild') + expect(args).toContain('--arch=arm64') + }) +}) diff --git a/config/scripts/windows-process-tree-patch-contract.test.mjs b/config/scripts/windows-process-tree-patch-contract.test.mjs new file mode 100644 index 00000000000..f9c1c90c91a --- /dev/null +++ b/config/scripts/windows-process-tree-patch-contract.test.mjs @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('Windows process-tree patch contract', () => { + it('keeps the patch LF-only and hash-synced with the lockfile', () => { + const patchBytes = readFileSync( + join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch') + ) + // pnpm hashes patches CRLF-normalized, so CR bytes cannot affect install + // behavior; keep the file LF-only so the bytes match main and diff clean. + expect(patchBytes.includes(0x0d)).toBe(false) + const patchHash = createHash('sha256') + .update(patchBytes.toString('utf8').replaceAll('\r\n', '\n')) + .digest('hex') + const lockfile = readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8') + expect(lockfile).toContain(`hash: ${patchHash}`) + }) +}) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 9f3d8d66472..423149950ce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -95,10 +95,29 @@ negotiated capabilities differ from the contract. Adding an optional field keeps green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red. -The harness covers the terminal stream only. It does **not** cover the session-tab -sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or -the relay transport. A change on those paths still needs its own reasoning against -the three rules above. +`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the +same two builds over the structured `agentSession.*` surface. Because a released build +cannot name a capability string its own source never contains, the old side's advertised +list and registered method names are read from the extracted checkout rather than +hand-written. It covers the three skews that surface can fail on: + +- an old client — advertising only what the baseline build defines — is told the whole + surface does not exist and reaches no host method; +- a new client against the old dispatcher gets `method_not_found` on every method, and + can see the absence during negotiation instead of by calling; +- a cursor survives a host restart: the client's fence is refused as stale with the live + one attached, and resuming from the held cursor replays only what it missed. + +Run it with: + +```bash +pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +``` + +The harness covers the terminal stream and the structured agent-session surface. It does +**not** cover the session-tab sync channel, legacy agent-session publications, file or Git +RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its +own reasoning against the three rules above. ## Worked example: `agentWait` on terminal and worker reads diff --git a/package.json b/package.json index c8e59299c70..7d0cc956642 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", + "proper-lockfile": "4.1.2", "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "^17.0.8", @@ -201,6 +202,7 @@ "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", "@types/node": "^25.6.0", + "@types/proper-lockfile": "^4.1.4", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c36d26ae93..75b6ec89192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: posthog-node: specifier: ^5.33.3 version: 5.33.3 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 psl: specifier: 1.15.0 version: 1.15.0 @@ -194,6 +197,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.9.5 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -3172,6 +3178,9 @@ packages: '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -3390,7 +3399,6 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - '@xterm/addon-fit@0.12.0-beta.287': resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==} peerDependencies: @@ -9435,6 +9443,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/qrcode@1.5.6': dependencies: '@types/node': 25.9.5 diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index d19666556c6..cd48e0f4c32 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -164,6 +164,52 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBe(1) }) + it('prints the Structured Chat recovery action for a refused worker start', async () => { + callMock.mockResolvedValue({ + result: { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.', + effects: [], + residualResources: [] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags: new Map([ + ['task', 'task_1'], + ['terminal', 'term_worker'], + ['from', 'term_coord'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as + | ((result: { + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + }) => string) + | undefined + expect( + formatter?.({ + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + }) + ).toMatch(/Structured Chat.*Switch it to Terminal.*orca orchestration worker-start/s) + }) + it('prints a reveal warning for a live background worker', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 7d08658fc66..18832ba28a6 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -5,6 +5,8 @@ import { printHelp } from '../help' import { COMMAND_SPECS } from '../specs' import { TERMINAL_HANDLERS } from './terminal' +const ORIGINAL_EXIT_CODE = process.exitCode + describe('terminal close CLI', () => { afterEach(() => { vi.restoreAllMocks() @@ -65,6 +67,7 @@ describe('terminal close CLI', () => { describe('terminal send CLI', () => { afterEach(() => { vi.restoreAllMocks() + process.exitCode = ORIGINAL_EXIT_CODE }) it('marks combined text and Enter as an agent prompt candidate', async () => { @@ -94,6 +97,44 @@ describe('terminal send CLI', () => { }) }) + it('explains that Structured Chat blocked a refused send and how to recover', async () => { + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: false, + bytesWritten: 0, + agentSessionRefusal: { + code: 'agent_session_conflict', + sessionId: 'session-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + } + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = undefined + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text', 'review'], + ['enter', true] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: false + }) + + expect(console.log).toHaveBeenCalledWith( + expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s) + ) + expect(process.exitCode).toBe(1) + }) + it('keeps text-only and bare Enter sends as direct terminal input', async () => { const call = vi.fn().mockResolvedValue({ result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } } diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 00c0a5cd807..d0ced262a43 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -115,6 +115,9 @@ export const TERMINAL_HANDLERS: Record = { client: { id: 'orca-cli', type: 'desktop' } }) printResult(result, json, formatTerminalSend) + if (!result.result.send.accepted) { + process.exitCode = 1 + } }, 'terminal wait': async ({ flags, client, cwd, json }) => { const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms') diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 4d57e7693a1..edf4cbaa22c 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,4 +1,5 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' +import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' import type { RuntimeTerminalClose, RuntimeTerminalCreate, @@ -181,6 +182,12 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string } export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { + if (result.send.agentSessionRefusal) { + const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send') + if (copy) { + return copy + } + } return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` } diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 06502cd4a0b..4ba72fdd43b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -67,6 +67,12 @@ const aiVaultSessionSchema = z.object({ queuedMessageCount: z.number().default(0), subagentTranscriptCount: z.number().default(0), resumeCommand: z.string(), + structuredSession: z + .object({ + sessionId: z.string().min(1).max(512), + workspaceId: z.string().min(1).max(512) + }) + .optional(), subagent: z .object({ parentSessionId: z.string(), diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts new file mode 100644 index 00000000000..b7104e50a0c --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + assertLegacyAiVaultResumeAllowed, + assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSessions +} from './structured-session-ownership' + +const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +describe('structured AI Vault ownership', () => { + afterEach(() => setStructuredAgentSessionHost(null)) + + it('hides owned rows from legacy clients and annotates them for capable clients', () => { + installOwnership() + const result = listResult() + + expect(projectStructuredAiVaultSessions(result, false).sessions).toEqual([]) + expect(projectStructuredAiVaultSessions(result, true).sessions[0]).toMatchObject({ + structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } + }) + }) + + it('derives typed refusals from the single writer predicate for live and proving leases', async () => { + installOwnership() + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: 'codex', + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' + }) + ).toThrow('agent_session_conflict') + + installOwnership({ + lease: agentSessionLeaseFixture({ + handoffStage: 'new-owner-proving', + claimStatus: 'reserved', + ownerProcess: null + }) + }) + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `codex resume '${PROVIDER_SESSION}'`, + async () => undefined + ) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it.each([ + `codex resume --last`, + `claude --resume`, + `claude -r`, + `claude --continue`, + `claude -c`, + // `--continue` takes no session id, so the trailing token is a prompt — + // reading it as a target would admit a writer onto the owned session. + `claude --continue "keep going"`, + `claude -c 019fd532-7c11-7a90-b6de-4e1a2c3d5f61` + ])('refuses resume commands without a provably different target: %s', async (command) => { + installOwnership(command.startsWith('claude') ? { provider: 'claude' } : {}) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) + + it('allows a resume command that names a different provider session', async () => { + installOwnership() + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + 'codex resume 019fd532-7c11-7a90-b6de-4e1a2c3d5f61', + async () => undefined + ) + ).resolves.toBeUndefined() + }) +}) + +function installOwnership(overrides: Partial = {}): void { + const ownership: StructuredProviderSessionOwnership = { + sessionId: 'session-alpha', + workspaceId: 'workspace-1', + provider: 'codex', + providerSessionId: PROVIDER_SESSION, + lease: agentSessionLeaseFixture(), + ...overrides + } + const record = agentSessionRecordFixture(ownership.lease) + setStructuredAgentSessionHost({ + deps: { + store: { + listRecords: () => [ + { + ...record, + sessionId: ownership.sessionId, + location: { ...record.location, workspaceId: ownership.workspaceId }, + provider: ownership.provider, + providerHandleChain: [ + { + ...record.providerHandleChain[0]!, + handle: { provider: ownership.provider, threadId: ownership.providerSessionId } + } + ], + lease: { ...ownership.lease, sessionId: ownership.sessionId } + } + ] + } + } + } as never) +} + +function listResult(): AiVaultListResult { + const session: AiVaultSession = { + id: `local:codex:${PROVIDER_SESSION}`, + executionHostId: 'local', + agent: 'codex', + sessionId: PROVIDER_SESSION, + title: 'Owned', + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-11T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume '${PROVIDER_SESSION}'`, + subagent: null + } + return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' } +} diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts new file mode 100644 index 00000000000..03c84f023c6 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -0,0 +1,186 @@ +import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + listStructuredProviderSessionOwnership, + type StructuredProviderSessionOwnership +} from '../native-chat/agent-session-wire/structured-provider-session-ownership' + +export function projectStructuredAiVaultSessions( + result: AiVaultListResult, + structuredSupported: boolean +): AiVaultListResult { + const host = getStructuredAgentSessionHost() + if (!host) { + return result + } + const sessions = result.sessions.flatMap((session) => { + const ownership = findSessionOwnership(session) + if (!ownership) { + return [session] + } + if (!structuredSupported) { + return [] + } + return [ + { + ...session, + structuredSession: { + sessionId: ownership.sessionId, + workspaceId: ownership.workspaceId + } + } + ] + }) + return sessions.length === result.sessions.length && + sessions.every((row, index) => row === result.sessions[index]) + ? result + : { ...result, sessions } +} + +export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { + const ownership = findResumeOwnership(args) + if (ownership) { + refuseLegacyWriter(ownership) + } +} + +export async function assertLegacyAiVaultResumeCommandAllowed( + command: string, + ensureHost: () => Promise +): Promise { + if (!isPotentialStructuredResumeCommand(command)) { + return + } + await ensureHost() + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + for (const ownership of listOwnership()) { + if (isResumeCommandFor(command, ownership)) { + refuseLegacyWriter(ownership) + } + } +} + +function isPotentialStructuredResumeCommand(command: string): boolean { + return parseResumeInvocation(command) !== null +} + +function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null { + if (session.agent !== 'codex' && session.agent !== 'claude') { + return null + } + return findOwnership(session.agent, session.sessionId) +} + +function findResumeOwnership( + args: AiVaultPrepareSessionResumeArgs +): StructuredProviderSessionOwnership | null { + if (args.agent !== 'codex' && args.agent !== 'claude') { + return null + } + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + const exact = args.sessionId ? findOwnership(args.agent, args.sessionId) : null + if (exact) { + return exact + } + const fileName = args.filePath.split(/[\\/]/).at(-1) ?? '' + return ( + listOwnership().find( + (ownership) => + ownership.provider === args.agent && fileName.includes(ownership.providerSessionId) + ) ?? null + ) +} + +function findOwnership( + provider: 'claude' | 'codex', + providerSessionId: string +): StructuredProviderSessionOwnership | null { + return ( + listOwnership().find( + (ownership) => + ownership.provider === provider && ownership.providerSessionId === providerSessionId + ) ?? null + ) +} + +function listOwnership(): StructuredProviderSessionOwnership[] { + const host = getStructuredAgentSessionHost() + return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : [] +} + +function isResumeCommandFor( + command: string, + ownership: StructuredProviderSessionOwnership +): boolean { + const invocation = parseResumeInvocation(command) + if (!invocation || invocation.provider !== ownership.provider) { + return false + } + // A target-less resume (--last, --continue, or a bare --resume/-r) may pick + // any provider session, so it cannot be admitted while one is structured. + // Only an explicit target that differs from this owned session is safe. + return invocation.target === null || invocation.target === ownership.providerSessionId +} + +type ResumeInvocation = { + provider: 'codex' | 'claude' + target: string | null +} + +function parseResumeInvocation(command: string): ResumeInvocation | null { + // Keep this deliberately conservative: shell quoting is normalized only + // enough to identify executable/flag tokens; an unrecognized shape is not + // treated as proof that a different session is being resumed. + const tokens = command.match(/"[^"\\]*(?:\\.[^"\\]*)*"|'[^']*'|[^\s]+/g) ?? [] + const normalized = tokens.map((token) => token.replace(/^['"]|['"]$/g, '')) + const executableIndex = normalized.findIndex((token) => + /(?:^|[\\/])(?:codex|claude)(?:\.exe)?$/i.test(token) + ) + if (executableIndex === -1) { + return null + } + const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude' + const args = normalized.slice(executableIndex + 1) + // `--continue`/`-c` resume the most recent session and never take an id, so a + // following token is a prompt, not a target — they are always target-less. + const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c'] + const targetlessIndex = args.findIndex((token) => targetlessFlags.includes(token.toLowerCase())) + if (targetlessIndex !== -1) { + return { provider, target: null } + } + const resumeFlags = provider === 'codex' ? ['resume'] : ['--resume', '-r'] + const inlineIndex = args.findIndex( + (token) => + provider === 'claude' && + (token.toLowerCase().startsWith('--resume=') || token.toLowerCase().startsWith('-r=')) + ) + if (inlineIndex !== -1) { + const target = args[inlineIndex]!.slice(args[inlineIndex]!.indexOf('=') + 1) + return { provider, target: target.length > 0 ? target : null } + } + const markerIndex = args.findIndex((token) => resumeFlags.includes(token.toLowerCase())) + if (markerIndex === -1) { + return null + } + const candidate = args[markerIndex + 1] + return { + provider, + target: candidate && !candidate.startsWith('-') ? candidate : null + } +} + +function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never { + throw new Error( + agentSessionLeaseAdmitsWriter(ownership.lease) + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + ) +} diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts new file mode 100644 index 00000000000..1d13e6ec7c2 --- /dev/null +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -0,0 +1,21 @@ +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' + +export function claudeProviderHandleLink(input: { + sessionId: string + leafUuid: string | null + resumed: boolean + origin?: 'adopted' + fence: number + linkId?: string + observedAt: number +}): AgentSessionProviderHandleLink { + return { + linkId: + input.linkId ?? + `claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128), + handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid }, + origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), + mintedAtFence: input.fence, + observedAt: input.observedAt + } +} diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts new file mode 100644 index 00000000000..d7065caa275 --- /dev/null +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -0,0 +1,128 @@ +import { readFile } from 'node:fs/promises' + +const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000 + +type TranscriptNode = { + parentUuid: string | null + sessionId: string | null +} + +export type ClaudeTranscriptBranchProof = { + leafUuid: string + relation: 'initial' | 'same' | 'descendant' +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +function transcriptError(reason: string): Error { + return new Error(`Claude transcript branch proof failed: ${reason}`) +} + +export class ClaudeTranscriptTailIncompleteError extends Error { + constructor() { + super('Claude transcript branch proof failed: malformed JSONL') + this.name = 'ClaudeTranscriptTailIncompleteError' + } +} + +export function proveClaudeTranscriptBranchFromJsonl(input: { + contents: string + providerSessionId: string + previousLeafUuid: string | null +}): ClaudeTranscriptBranchProof { + const nodes = new Map() + let leafUuid: string | null = null + const lines = input.contents.split('\n') + for (const [index, line] of lines.entries()) { + if (!line.trim()) { + continue + } + let record: unknown + try { + record = JSON.parse(line) + } catch { + if (index === lines.length - 1 && !input.contents.endsWith('\n')) { + throw new ClaudeTranscriptTailIncompleteError() + } + throw transcriptError('malformed JSONL') + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) { + throw transcriptError('non-object record') + } + const row = record as Record + if (row.type === 'last-prompt') { + const markerSessionId = nonEmptyString(row.sessionId) + const markerLeaf = nonEmptyString(row.leafUuid) + if (markerSessionId !== input.providerSessionId || !markerLeaf) { + throw transcriptError('invalid last-prompt marker') + } + leafUuid = markerLeaf + } + const uuid = nonEmptyString(row.uuid) + if (!uuid) { + continue + } + const parentUuid = row.parentUuid === null ? null : nonEmptyString(row.parentUuid) + if (row.parentUuid !== null && !parentUuid) { + throw transcriptError(`record ${uuid} has no parent identity`) + } + const sessionId = nonEmptyString(row.sessionId) + const existing = nodes.get(uuid) + if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) { + throw transcriptError(`record ${uuid} has conflicting ancestry`) + } + nodes.set(uuid, { parentUuid, sessionId }) + } + if (!leafUuid) { + throw transcriptError('missing last-prompt marker') + } + const leaf = nodes.get(leafUuid) + if (!leaf || leaf.sessionId !== input.providerSessionId) { + throw transcriptError('marker leaf is missing from the session graph') + } + const previousLeafUuid = input.previousLeafUuid + if (!previousLeafUuid) { + return { leafUuid, relation: 'initial' } + } + const previous = nodes.get(previousLeafUuid) + if (!previous || previous.sessionId !== input.providerSessionId) { + throw transcriptError('previous cursor is missing from the session graph') + } + if (leafUuid === previousLeafUuid) { + return { leafUuid, relation: 'same' } + } + const visited = new Set() + let cursor: string | null = leafUuid + for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (visited.has(cursor)) { + throw transcriptError('cycle in parentUuid ancestry') + } + visited.add(cursor) + const node = nodes.get(cursor) + if (!node || node.sessionId !== input.providerSessionId) { + throw transcriptError(`missing ancestor ${cursor}`) + } + cursor = node.parentUuid + if (cursor === previousLeafUuid) { + return { leafUuid, relation: 'descendant' } + } + } + if (cursor !== null) { + throw transcriptError('ancestry exceeds the bounded proof limit') + } + throw transcriptError('latest marker is on a sibling branch') +} + +export async function proveClaudeTranscriptBranch(input: { + transcriptPath: string + providerSessionId: string + previousLeafUuid: string | null +}): Promise { + return proveClaudeTranscriptBranchFromJsonl({ + contents: await readFile(input.transcriptPath, 'utf8'), + providerSessionId: input.providerSessionId, + previousLeafUuid: input.previousLeafUuid + }) +} diff --git a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts index aa09855eeb5..86067a584d3 100644 --- a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts +++ b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts @@ -61,7 +61,7 @@ describe('CodexRuntimeHomeService', () => { '', '[model_providers.codex-lb]', 'base_url = "https://codex-lb.example.test/v1"', - 'env_key = "CODEX_LB_API_KEY"', + 'env_key = "EXAMPLE_GATEWAY_TOKEN"', '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') diff --git a/src/main/codex-accounts/service-account-add-login.test.ts b/src/main/codex-accounts/service-account-add-login.test.ts index 7f645ad0bea..68aa3e2f3af 100644 --- a/src/main/codex-accounts/service-account-add-login.test.ts +++ b/src/main/codex-accounts/service-account-add-login.test.ts @@ -176,7 +176,7 @@ describe('CodexAccountService config sync', () => { '[model_providers.codex-lb]', 'name = "Codex load balancer"', 'base_url = "https://codex-lb.example.test/v1"', - 'env_key = "CODEX_LB_API_KEY"', + 'env_key = "EXAMPLE_GATEWAY_TOKEN"', '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 30d4941ad57..1a1c402352f 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -192,16 +192,21 @@ describe('killCodexAppServerProcessTree', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('kills the direct app-server process on non-Windows hosts', () => { + it('kills the launcher descendants before the direct process on non-Windows hosts', () => { const child = { pid: 1234, kill: vi.fn(() => true) as ChildProcess['kill'] } - const spawnImpl = vi.fn() as unknown as typeof spawn + const descendants = { unref: vi.fn(), on: vi.fn() } + const spawnImpl = vi.fn(() => descendants) as unknown as typeof spawn killCodexAppServerProcessTree(child, { platform: 'linux', spawnImpl }) - expect(spawnImpl).not.toHaveBeenCalled() + expect(spawnImpl).toHaveBeenCalledWith('pkill', ['-KILL', '-P', '1234'], { stdio: 'ignore' }) + // A missing pkill arrives as an async 'error' event; unhandled, it would + // take down the main process. + expect(descendants.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(descendants.unref).toHaveBeenCalledOnce() expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) }) diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts new file mode 100644 index 00000000000..495846bc1cf --- /dev/null +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -0,0 +1,27 @@ +export type CodexAppServerServerRequest = { + id: number | string + method: string + params: unknown +} + +export type CodexAppServerConnectionHandlers = { + onNotification?: (method: string, params: unknown) => void + onServerRequest?: (request: CodexAppServerServerRequest) => void + onUnhandledFrame?: (kind: string, payload: unknown) => void + onExit?: (error: Error) => void +} + +export type CodexAppServerConnection = { + readonly pid: number | undefined + readonly closed: boolean + request: ( + method: string, + params?: Record, + options?: { timeoutMs?: number } + ) => Promise + notify: (method: string, params?: Record) => void + respond: (id: number | string, result: unknown) => void + respondWithError: (id: number | string, code: number, message: string) => void + /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ + close: () => Promise +} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts new file mode 100644 index 00000000000..55a9b52deaa --- /dev/null +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -0,0 +1,549 @@ +import { EventEmitter } from 'node:events' +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { + isCodexAppServerRequestError, + openCodexAppServerConnection, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' + +const originalCodexHome = process.env.CODEX_HOME + +afterEach(() => { + vi.useRealTimers() + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } +}) + +/** + * A real `node -e` child speaking the same JSONL framing Codex does. Slower than + * a stub, but it is the only thing that proves the spawn, the environment, and + * both traffic directions actually work end to end. + */ +const FAKE_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) + } + if (message.method === 'test/cwd') { + return send({ id: message.id, result: { cwd: process.cwd() } }) + } + if (message.method === 'test/notify') { + send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) + return send({ id: message.id, result: {} }) + } + if (message.method === 'test/ask') { + return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) + } + if (message.method === 'test/refuse') { + return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) + } + if (message.method === 'test/missing') { + return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) + } + if (message.id === 99) { + return send({ method: 'test/answered', params: message }) + } + }) +` + +async function openFakeServer( + handlers: CodexAppServerConnectionHandlers = {}, + env?: Record, + envToDelete?: string[], + cwd?: string +): Promise { + return openCodexAppServerConnection( + { command: process.execPath, args: ['-e', FAKE_APP_SERVER], env, envToDelete, cwd }, + handlers + ) +} + +type StubChild = EventEmitter & { + stdout: PassThrough + stderr: PassThrough + stdin: PassThrough + pid: number + kill: ReturnType +} + +/** Full control over framing and death, which a real child cannot give. */ +function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { + child: StubChild + spawnImpl: typeof spawnProcess + written: Record[] +} { + const child = new EventEmitter() as StubChild + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.stdin = new PassThrough() + // Keep the synthetic pid outside any real process table so teardown never + // mistakes an unrelated process for this stub. + child.pid = 9_999_999 + child.kill = vi.fn() + const written: Record[] = [] + child.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf8').split('\n')) { + if (line.trim()) { + written.push(JSON.parse(line) as Record) + } + } + }) + if (options.exitOnStdinEnd !== false) { + child.stdin.on('finish', () => child.emit('exit', 0, null)) + } + return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written } +} + +/** Answers the handshake so `openCodexAppServerConnection` can resolve. */ +function answerInitialize(child: StubChild): void { + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) +} + +/** Stream writes land a tick later, so the stderr tail is only complete here. */ +async function flushStreams(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected the call to reject') + }, + (error: Error) => error + ) +} + +describe('openCodexAppServerConnection', () => { + it('advertises the experimental API required for rollout-path resume', async () => { + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + expect(written[0]).toMatchObject({ + method: 'initialize', + params: { capabilities: { experimentalApi: true } } + }) + await connection.close() + }) + + it('completes the handshake and keeps the child alive across calls', async () => { + const notifications: { method: string; params: unknown }[] = [] + const connection = await openFakeServer({ + onNotification: (method, params) => notifications.push({ method, params }) + }) + + await connection.request('test/notify') + await connection.request('test/notify') + + expect(connection.pid).toBeGreaterThan(0) + expect(connection.closed).toBe(false) + expect(notifications).toHaveLength(2) + expect(notifications[0]).toEqual({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-7' } } + }) + await connection.close() + expect(connection.closed).toBe(true) + }) + + it('applies the environment overlay after stripping inherited keys', async () => { + process.env.CODEX_HOME = '/tmp/inherited-home' + const pinned = await openFakeServer({}, { CODEX_HOME: '/tmp/pinned-home' }) + expect(await pinned.request('test/env')).toEqual({ codexHome: '/tmp/pinned-home' }) + await pinned.close() + + const stripped = await openFakeServer({}, undefined, ['CODEX_HOME']) + expect(await stripped.request('test/env')).toEqual({ codexHome: null }) + await stripped.close() + }) + + it('starts the provider in the resolved workspace directory', async () => { + const workspace = realpathSync(tmpdir()) + const connection = await openFakeServer({}, undefined, undefined, workspace) + + await expect(connection.request('test/cwd')).resolves.toEqual({ cwd: workspace }) + await connection.close() + }) + + it('routes a server request to the handler and writes the reply back', async () => { + const requests: { id: number | string; method: string }[] = [] + let resolveAnswered: (params: unknown) => void = () => {} + const answered = new Promise((resolve) => { + resolveAnswered = resolve + }) + const connection = await openFakeServer({ + onServerRequest: (request) => { + requests.push({ id: request.id, method: request.method }) + connection.respond(request.id, { decision: 'accept' }) + }, + onNotification: (method, params) => { + if (method === 'test/answered') { + resolveAnswered(params) + } + } + }) + + connection.notify('test/ask') + + expect(await answered).toEqual({ id: 99, result: { decision: 'accept' } }) + expect(requests).toEqual([{ id: 99, method: 'item/fileChange/requestApproval' }]) + await connection.close() + }) + + it('classifies a refusal apart from a missing method', async () => { + const connection = await openFakeServer() + + const refusal = await connection.request('test/refuse').catch((error: unknown) => error) + const missing = await connection.request('test/missing').catch((error: unknown) => error) + + expect(isCodexAppServerRequestError(refusal)).toBe(true) + expect((refusal as Error).message).toContain('bad params') + expect(isCodexAppServerUnsupportedError(missing)).toBe(true) + expect(isCodexAppServerRequestError(missing)).toBe(false) + await connection.close() + }) + + it('reassembles a message split mid-character across chunks', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const notifications: unknown[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onNotification: (_method, params) => notifications.push(params) }, + spawnImpl + ) + + const payload = Buffer.from( + `${JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: '日本語' } })}\n`, + 'utf8' + ) + const split = payload.indexOf(Buffer.from('日', 'utf8')) + 1 + child.stdout.write(payload.subarray(0, split)) + child.stdout.write(payload.subarray(split)) + await vi.waitFor(() => expect(notifications).toHaveLength(1)) + + expect(notifications[0]).toEqual({ delta: '日本語' }) + await connection.close() + }) + + it('surfaces valid but unclassified frames instead of dropping them', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const frames: { kind: string; payload: unknown }[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) }, + spawnImpl + ) + + child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) + child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`) + await vi.waitFor(() => expect(frames).toHaveLength(2)) + + expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched']) + await connection.close() + }) + + it('fails in-flight requests and reports an unexpected exit once', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + const inFlight = rejection(connection.request('turn/start')) + child.stderr.write('codex crashed\n') + await flushStreams() + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect((await inFlight).message).toContain('codex crashed') + expect(exits).toHaveLength(1) + await connection.close() + }) + + it('classifies a CLI without the app-server subcommand as unsupported', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + const opening = openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ).catch((error: unknown) => error) + + child.stderr.write("error: unrecognized subcommand 'app-server'\n") + await flushStreams() + child.emit('exit', 2, null) + child.emit('close', 2, null) + + expect(isCodexAppServerUnsupportedError(await opening)).toBe(true) + }) + + it('exposes an unproven handshake child for later cleanup', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + child.stdin.once('data', () => { + child.stdout.write( + `${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n` + ) + }) + const opening = rejection( + openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + ) + + await vi.advanceTimersByTimeAsync(5_000) + const error = (await opening) as Error & { connection?: CodexAppServerConnection } + + expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') + expect(error.connection).toBeDefined() + child.emit('close', 1, null) + await expect(error.connection?.close()).resolves.toBe(true) + }) + + it('times out one request without ending the connection', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const slow = rejection(connection.request('turn/start', undefined, { timeoutMs: 50 })) + await vi.advanceTimersByTimeAsync(60) + + expect((await slow).name).toBe('CodexAppServerTimeoutError') + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(0) + }) + + it('kills a child that ignores stdin EOF', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const closing = connection.close() + await vi.advanceTimersByTimeAsync(2_000) + await closing + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + }) + + it('reports unproven close when forced termination did not produce an exit event', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + await expect(connection.close()).resolves.toBe(false) + }, 10_000) + + it('shares one eventual exit proof across concurrent close callers', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + child.kill.mockImplementation(() => { + setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) + return true + }) + + const first = connection.close() + const second = connection.close() + await vi.advanceTimersByTimeAsync(4_100) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL']) + }) + + it('allows a later close to observe exit after an unproven attempt', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const first = connection.close() + await vi.advanceTimersByTimeAsync(5_000) + await expect(first).resolves.toBe(false) + child.emit('exit', 0, null) + + await expect(connection.close()).resolves.toBe(true) + }) + + it('ends the connection rather than buffering an oversized line', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdout.write('x'.repeat(1024 * 1024 + 1)) + + expect((await inFlight).message).toContain('oversized') + expect(exits[0]).toContain('oversized') + await connection.close() + }) + + it.each([ + { + kind: 'notification', + frame: { method: 'turn/started', params: { turn: { id: 'turn-1' } } } + }, + { + kind: 'server request', + frame: { id: 41, method: 'item/fileChange/requestApproval', params: { itemId: 'item-1' } } + } + ])('surfaces a synchronous $kind handler failure as a terminal exit', async ({ frame }) => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const fail = (): never => { + throw new Error('structured sink failed') + } + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onNotification: fail, + onServerRequest: fail, + onExit: (error) => exits.push(error.message) + }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdout.write(`${JSON.stringify(frame)}\n`) + + expect((await inFlight).message).toContain('structured sink failed') + expect(exits).toEqual([expect.stringContaining('structured sink failed')]) + expect(connection.closed).toBe(true) + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + await connection.close() + }) + + it('reports one exit for a death that arrives through two listeners', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + // The oversized line kills the child, so its own `close` lands afterwards. + child.stdout.write('x'.repeat(1024 * 1024 + 1)) + child.stderr.write('killed\n') + await flushStreams() + child.emit('exit', null, 'SIGKILL') + child.emit('close', null, 'SIGKILL') + + expect(exits).toHaveLength(1) + // The first cause survives; the generic exit that follows does not overwrite it. + expect(exits[0]).toContain('oversized') + await connection.close() + }) + + it('treats a broken stdin pipe as the end of the transport', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdin.emit('error', new Error('write EPIPE')) + + expect((await inFlight).message).toContain('EPIPE') + expect(exits).toHaveLength(1) + // A child nobody can write to is not a live session: the owner must see the + // connection as gone rather than keep issuing calls that can only time out. + expect(connection.closed).toBe(true) + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') + await connection.close() + }) + + it('keeps a graceful close quiet when stdin breaks during the reap', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE'))) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + await connection.close() + + expect((await inFlight).message).toContain('EPIPE') + expect(exits).toHaveLength(0) + }) +}) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts new file mode 100644 index 00000000000..23b7068b76a --- /dev/null +++ b/src/main/codex/codex-app-server-connection.ts @@ -0,0 +1,349 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import { createProviderSpawnSpec } from './codex-app-server-posix-supervisor' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' +import { initializeCodexAppServerConnection } from './codex-app-server-handshake' +import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' +import { isAppServerRecord, parseCodexAppServerJsonLine } from './codex-app-server-jsonl' +import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' +import { CodexAppServerRequestError } from './codex-app-server-request-error' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { waitForProcessExitUntil } from './codex-process-exit-deadline' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexMethodNotFoundError +} from './codex-app-server-session' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers +} from './codex-app-server-connection-types' + +export type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerServerRequest +} from './codex-app-server-connection-types' +export { + CodexAppServerRequestError, + isCodexAppServerRequestError +} from './codex-app-server-request-error' + +// Structured chat needs a persistent bidirectional child and per-request deadlines; +// the request-scoped app-server runner cannot carry approvals or streamed turns. + +export type CodexAppServerLaunch = { + command: string + args: string[] + /** Workspace directory used by the provider process itself. */ + cwd?: string + /** Overlay on the inherited environment — the pinned CODEX_HOME lives here. */ + env?: Record + /** Keys stripped after the overlay, matching `CodexAppServerInvocation`. */ + envToDelete?: readonly string[] +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 +const STDERR_TAIL_MAX_BYTES = 8192 +const STDOUT_LINE_MAX_BYTES = 1024 * 1024 + +type PendingRequest = { + method: string + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** + * Spawns `codex app-server`, completes the initialize handshake, and returns a + * connection that stays open until `close()`. Rejects — after reaping the child + * — when the handshake cannot complete. + */ +export async function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess +): Promise { + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete childEnv[key] + } + const spawnSpec = createProviderSpawnSpec(launch, childEnv, process.platform) + const child = spawnImpl(spawnSpec) + const spawnToken = launch.env?.[CODEX_SPAWN_TOKEN_ENV] + + function terminateProcessTree(): Promise { + // The supervisor and provider own separate POSIX groups so the supervisor can prove the + // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. + return terminateCodexAppServerProcessTree(child, spawnToken) + } + + const pending = new Map() + let stderrTail = '' + let nextRequestId = 1 + let exited = false + let exitObserved = false + let closing = false + const exitProof = new RetryableProcessExitProof() + /** First terminal cause, or null while the transport is still usable. Set once: + * a child that dies reaches us through several listeners, and the specific + * first cause is the one worth reporting. */ + let terminalError: Error | null = null + + let resolveExit = (): void => undefined + const exitPromise = new Promise((resolve) => { + resolveExit = resolve + }) + + function observeExit(): void { + exited = true + exitObserved = true + resolveExit() + } + + child.on('exit', observeExit) + + function buildExitError(cause?: Error): Error { + return buildCodexAppServerExitError(stderrTail, cause) + } + + function failPending(error: Error): void { + for (const waiter of pending.values()) { + clearTimeout(waiter.timer) + waiter.reject(error) + } + pending.clear() + } + + /** A death nobody asked for kills every in-flight call AND tells the owner, + * which is the only signal the session has that its lease is now worthless. + * Once only: an oversized line kills the child and its `close` arrives after, + * and a spawn failure arrives as both `error` and `close`. */ + function handleUnexpectedEnd(cause?: Error): void { + if (terminalError) { + return + } + terminalError = buildExitError(cause) + failPending(terminalError) + if (!closing) { + handlers.onExit?.(terminalError) + } + } + + child.on('error', (error) => { + handleUnexpectedEnd(error) + }) + child.on('close', () => { + observeExit() + handleUnexpectedEnd() + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + child.stdin.on('error', (error) => { + // A broken pipe is terminal, not one failed write: every later request can + // only error or time out, so the session must learn its lease is worthless + // instead of staying live in front of a child nobody can reach. During a + // close the reap is already under way and `exited` must stay honest, or + // `close` would skip the kill it still owes. + if (closing) { + failPending(error) + return + } + void terminateProcessTree() + handleUnexpectedEnd(error) + }) + + function dispatchMessage(message: Record): void { + const hasMethod = typeof message.method === 'string' + const hasId = typeof message.id === 'number' || typeof message.id === 'string' + if (hasMethod && hasId) { + handlers.onServerRequest?.({ + id: message.id as number | string, + method: message.method as string, + params: message.params + }) + return + } + if (hasMethod) { + handlers.onNotification?.(message.method as string, message.params) + return + } + if (typeof message.id !== 'number') { + handlers.onUnhandledFrame?.('frame:unclassified', message) + return + } + const waiter = pending.get(message.id) + if (!waiter) { + handlers.onUnhandledFrame?.('response:unmatched', message) + return + } + pending.delete(message.id) + clearTimeout(waiter.timer) + const error = message.error + if (isAppServerRecord(error)) { + const detail = typeof error.message === 'string' ? error.message : 'unknown error' + waiter.reject( + isCodexMethodNotFoundError(error) + ? new CodexAppServerUnsupportedError( + `codex app-server does not support ${waiter.method}: ${detail}` + ) + : new CodexAppServerRequestError( + waiter.method, + typeof error.code === 'number' ? error.code : null, + `codex app-server ${waiter.method} failed: ${detail}` + ) + ) + return + } + waiter.resolve(message.result) + } + + let stdoutBuffer = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdoutBuffer += chunk + if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + child.stdout.destroy() + void terminateProcessTree() + handleUnexpectedEnd(new Error('codex app-server emitted an oversized JSONL line')) + return + } + let newlineIndex: number + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + if (!line) { + continue + } + const parsed = parseCodexAppServerJsonLine(line) + if (!parsed) { + handlers.onUnhandledFrame?.('frame:invalid-json', line) + continue + } + try { + dispatchMessage(parsed) + } catch (error) { + child.stdout.destroy() + void terminateProcessTree() + handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) + return + } + } + }) + + function sendLine(payload: Record): void { + child.stdin.write(`${JSON.stringify(payload)}\n`) + } + + function notify(method: string, params?: Record): void { + if (exited || terminalError) { + return + } + try { + sendLine(params === undefined ? { method } : { method, params }) + } catch { + // Fire-and-forget; the next request surfaces a dead child. + } + } + + function request( + method: string, + params?: Record, + options: { timeoutMs?: number } = {} + ): Promise { + if (closing) { + return Promise.reject(new Error(`codex app-server connection is closed (${method})`)) + } + if (terminalError) { + return Promise.reject(terminalError) + } + if (exited) { + return Promise.reject(buildExitError()) + } + const id = nextRequestId++ + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + return new Promise((resolve, reject) => { + // Why: per request, not per session — a chat session outlives every call, + // so only the individual call can carry a deadline. + const timer = setTimeout(() => { + pending.delete(id) + reject(new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`)) + }, timeoutMs) + pending.set(id, { method, resolve, reject, timer }) + try { + sendLine(params === undefined ? { method, id } : { method, id, params }) + } catch (error) { + pending.delete(id) + clearTimeout(timer) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + function writeResponse(payload: Record): void { + if (exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + return + } + try { + sendLine(payload) + } catch { + // The turn that asked is already gone with the child. + } + } + + function close(): Promise { + if (exitObserved) { + return Promise.resolve(true) + } + closing = true + return exitProof.run(async () => { + try { + child.stdin.end() + } catch { + // Already destroyed; the reap below still runs. + } + if (!exited) { + await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS) + if (!exited) { + const treeExited = await terminateProcessTree() + if (!treeExited) { + failPending(new Error('codex app-server process-tree exit was not proven')) + return false + } + await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) + } + } + failPending(new Error('codex app-server connection closed')) + return exitObserved + }) + } + + const connection: CodexAppServerConnection = { + get pid() { + return child.pid + }, + get closed() { + return closing || exited || terminalError !== null + }, + request, + notify, + respond: (id, result) => writeResponse({ id, result }), + respondWithError: (id, code, message) => writeResponse({ id, error: { code, message } }), + close + } + + try { + await initializeCodexAppServerConnection(connection) + } catch (error) { + if ((await close()) !== true) { + throw new CodexAppServerHandshakeExitUnprovenError(connection, error) + } + throw error instanceof CodexAppServerUnsupportedError || + error instanceof CodexAppServerTimeoutError + ? error + : buildExitError(error instanceof Error ? error : new Error(String(error))) + } + return connection +} diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts new file mode 100644 index 00000000000..85d8bc7dd6e --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -0,0 +1,19 @@ +// What a dead `codex app-server` child means to whoever was talking to it. The +// stderr tail is the only evidence: a CLI without the subcommand is a durable +// capability fact, and anything else is this run's crash. + +import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' +import { CodexAppServerUnsupportedError } from './codex-app-server-session' + +const EXIT_DETAIL_MAX_CHARS = 400 + +export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { + const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + if (stderrIndicatesMissingAppServer(stderrTail)) { + return new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ) + } + const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' + return new Error(`codex app-server connection ended${detail}`) +} diff --git a/src/main/codex/codex-app-server-handshake-exit-proof.ts b/src/main/codex/codex-app-server-handshake-exit-proof.ts new file mode 100644 index 00000000000..d3090b84946 --- /dev/null +++ b/src/main/codex/codex-app-server-handshake-exit-proof.ts @@ -0,0 +1,26 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' + +export class CodexAppServerHandshakeExitUnprovenError extends Error { + constructor( + readonly connection: CodexAppServerConnection, + cause: unknown + ) { + super('codex app-server handshake failed without process-exit proof', { cause }) + this.name = 'CodexAppServerHandshakeExitUnprovenError' + } +} + +export function isCodexAppServerHandshakeExitUnprovenError( + error: unknown +): error is CodexAppServerHandshakeExitUnprovenError { + const connection = + error instanceof Error && 'connection' in error + ? (error.connection as Partial | null) + : null + return ( + error instanceof Error && + error.name === 'CodexAppServerHandshakeExitUnprovenError' && + connection !== null && + typeof connection.close === 'function' + ) +} diff --git a/src/main/codex/codex-app-server-handshake.ts b/src/main/codex/codex-app-server-handshake.ts new file mode 100644 index 00000000000..9c89d653baa --- /dev/null +++ b/src/main/codex/codex-app-server-handshake.ts @@ -0,0 +1,22 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' + +const HANDSHAKE_TIMEOUT_MS = 15_000 + +export async function initializeCodexAppServerConnection( + connection: CodexAppServerConnection +): Promise { + await connection.request( + 'initialize', + { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + mcpServerOpenaiFormElicitation: false, + extensions: {} + } + }, + { timeoutMs: HANDSHAKE_TIMEOUT_MS } + ) + connection.notify('initialized') +} diff --git a/src/main/codex/codex-app-server-jsonl.ts b/src/main/codex/codex-app-server-jsonl.ts new file mode 100644 index 00000000000..3a65e1fb138 --- /dev/null +++ b/src/main/codex/codex-app-server-jsonl.ts @@ -0,0 +1,12 @@ +export function isAppServerRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseCodexAppServerJsonLine(line: string): Record | null { + try { + const parsed: unknown = JSON.parse(line) + return isAppServerRecord(parsed) ? parsed : null + } catch { + return null + } +} diff --git a/src/main/codex/codex-app-server-notification-schema.ts b/src/main/codex/codex-app-server-notification-schema.ts new file mode 100644 index 00000000000..a7319273cdb --- /dev/null +++ b/src/main/codex/codex-app-server-notification-schema.ts @@ -0,0 +1,78 @@ +// Stable ServerNotification method discriminators generated by codex-cli 0.147.0. +export const CODEX_APP_SERVER_NOTIFICATION_METHODS = [ + 'error', + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/deleted', + 'thread/unarchived', + 'thread/closed', + 'skills/changed', + 'thread/name/updated', + 'thread/goal/updated', + 'thread/goal/cleared', + 'thread/environment/connected', + 'thread/environment/disconnected', + 'thread/settings/updated', + 'thread/tokenUsage/updated', + 'turn/started', + 'hook/started', + 'turn/completed', + 'hook/completed', + 'turn/diff/updated', + 'turn/plan/updated', + 'item/started', + 'item/autoApprovalReview/started', + 'item/autoApprovalReview/completed', + 'item/completed', + 'rawResponseItem/completed', + 'rawResponse/completed', + 'item/agentMessage/delta', + 'item/plan/delta', + 'command/exec/outputDelta', + 'process/outputDelta', + 'process/exited', + 'item/commandExecution/outputDelta', + 'item/commandExecution/terminalInteraction', + 'item/fileChange/outputDelta', + 'item/fileChange/patchUpdated', + 'serverRequest/resolved', + 'item/mcpToolCall/progress', + 'mcpServer/oauthLogin/completed', + 'mcpServer/startupStatus/updated', + 'account/updated', + 'account/rateLimits/updated', + 'app/list/updated', + 'remoteControl/status/changed', + 'externalAgentConfig/import/progress', + 'externalAgentConfig/import/completed', + 'fs/changed', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + 'item/reasoning/textDelta', + 'thread/compacted', + 'model/rerouted', + 'model/verification', + 'turn/moderationMetadata', + 'model/safetyBuffering/updated', + 'warning', + 'guardianWarning', + 'deprecationNotice', + 'configWarning', + 'fuzzyFileSearch/sessionUpdated', + 'fuzzyFileSearch/sessionCompleted', + 'thread/realtime/started', + 'thread/realtime/itemAdded', + 'thread/realtime/transcript/delta', + 'thread/realtime/transcript/done', + 'thread/realtime/outputAudio/delta', + 'thread/realtime/sdp', + 'thread/realtime/error', + 'thread/realtime/closed', + 'windows/worldWritableWarning', + 'windowsSandbox/setupCompleted', + 'account/login/completed' +] as const + +export type CodexAppServerNotificationMethod = + (typeof CODEX_APP_SERVER_NOTIFICATION_METHODS)[number] diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts new file mode 100644 index 00000000000..f51157a443c --- /dev/null +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { CodexAppServerLaunch } from './codex-app-server-connection' +import { + createProviderSpawnSpec, + POSIX_PROVIDER_SUPERVISOR_SCRIPT, + supervisedPosixLaunch +} from './codex-app-server-posix-supervisor' + +const launch: CodexAppServerLaunch = { + command: '/opt/codex', + args: ['app-server', '--flag'], + cwd: '/work/repo', + env: { CODEX_HOME: '/tmp/codex' } +} + +describe('structured provider supervision', () => { + it('wraps POSIX launches in a detached supervisor and preserves the launch spec', () => { + const childEnv = { PATH: '/bin', CODEX_HOME: '/tmp/codex' } + const spec = supervisedPosixLaunch(launch, childEnv) + + expect(spec.command).toBe(process.execPath) + expect(spec.args).toEqual(['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT]) + expect(spec.env.PATH).toBe('/bin') + expect( + JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString()) + ).toEqual( + expect.objectContaining({ + command: '/opt/codex', + args: ['app-server', '--flag'], + cwd: '/work/repo' + }) + ) + expect( + JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString()) + ).not.toHaveProperty('env') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( + 'delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC' + ) + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('delete childEnv.ELECTRON_RUN_AS_NODE') + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== originalParent') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( + "process.stdin.once('close', scheduleOwnerShutdown)" + ) + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('detached: true') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain("process.kill(-child.pid, 'SIGKILL')") + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('providerGroupExists()') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('finishWithProviderOutcome(code, signal)') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1') + }) + + it('uses direct provider spawning on Windows because the job owns the tree', () => { + expect(createProviderSpawnSpec(launch, { PATH: '/bin' }, 'win32')).toEqual({ + program: '/opt/codex', + args: ['app-server', '--flag'], + env: { PATH: '/bin' }, + cwd: '/work/repo', + detached: false + }) + }) +}) diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/codex/codex-app-server-posix-supervisor.ts new file mode 100644 index 00000000000..f83ba6f374b --- /dev/null +++ b/src/main/codex/codex-app-server-posix-supervisor.ts @@ -0,0 +1,128 @@ +import type { CodexAppServerLaunch } from './codex-app-server-connection' + +/** Inline supervisor source kept dependency-free for the spawned Node child. */ +export const POSIX_PROVIDER_SUPERVISOR_SCRIPT = ` +const { spawn } = require('node:child_process') +const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) +const childEnv = { ...process.env } +delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC +delete childEnv.ELECTRON_RUN_AS_NODE +const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true +}) +const originalParent = process.ppid +let timer +let ownerShutdownTimer +let settling = false +const providerGroupExists = () => { + if (!child.pid) return false + try { + process.kill(-child.pid, 0) + return true + } catch (error) { + return Boolean(error && error.code !== 'ESRCH') + } +} +const reapOwnedProviderGroup = async () => { + if (!child.pid) return false + try { process.kill(-child.pid, 'SIGKILL') } catch (error) { + if (error && error.code !== 'ESRCH') return false + } + const deadline = Date.now() + 1500 + while (providerGroupExists()) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return true +} +const terminateOwnedGroup = () => { + if (settling) return + settling = true + clearInterval(timer) + void reapOwnedProviderGroup().then((reaped) => process.exit(reaped ? 137 : 1)) +} +const scheduleOwnerShutdown = () => { + if (settling || ownerShutdownTimer) return + // A normal close ends the provider's stdin first; allow it to flush and + // exit before forcing the group, while still bounding an orphaned child. + ownerShutdownTimer = setTimeout(terminateOwnedGroup, 1250) + ownerShutdownTimer.unref() +} +process.stdin.once('end', scheduleOwnerShutdown) +process.stdin.once('close', scheduleOwnerShutdown) +process.stdin.pipe(child.stdin) +child.stdout.pipe(process.stdout) +child.stderr.pipe(process.stderr) +for (const stream of [process.stdin, child.stdin, child.stdout, child.stderr]) stream.on('error', () => {}) +const finishWithProviderOutcome = (code, signal) => { + if (!signal) return process.exit(code ?? 1) + process.kill(process.pid, signal) +} +const reapProviderExit = async (code, signal) => { + if (settling) return + settling = true + clearInterval(timer) + if (ownerShutdownTimer) clearTimeout(ownerShutdownTimer) + if (!(await reapOwnedProviderGroup())) return process.exit(1) + finishWithProviderOutcome(code, signal) +} +timer = setInterval(() => { + // A detached supervisor is reparented when its owner exits. The new parent + // may be PID 1 or a platform subreaper, so any parent change is proof that + // this process group no longer has a live Orca owner. + if (process.ppid !== originalParent) { + terminateOwnedGroup() + } +}, 100) +timer.unref() +child.once('error', () => { + clearInterval(timer) + process.exit(127) +}) +child.once('exit', (code, signal) => { + void reapProviderExit(code, signal) +}) +` + +export function supervisedPosixLaunch( + launch: CodexAppServerLaunch, + childEnv: NodeJS.ProcessEnv, + cwd = launch.cwd ?? process.cwd() +): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + const supervisorSpec = Buffer.from( + JSON.stringify({ + command: launch.command, + args: launch.args, + cwd + }) + ).toString('base64') + return { + command: process.execPath, + args: ['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT], + // Electron's executable needs Node mode for the inline supervisor. The + // marker is removed above so providers never inherit Electron semantics. + env: { + ...childEnv, + ELECTRON_RUN_AS_NODE: '1', + ORCA_PROVIDER_SUPERVISOR_SPEC: supervisorSpec + } + } +} + +export function createProviderSpawnSpec( + launch: CodexAppServerLaunch, + childEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): { program: string; args: string[]; env: NodeJS.ProcessEnv; cwd: string; detached: boolean } { + const supervised = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) + return { + program: supervised?.command ?? launch.command, + args: supervised?.args ?? launch.args, + env: supervised?.env ?? childEnv, + cwd: launch.cwd ?? process.cwd(), + detached: platform !== 'win32' + } +} diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/codex/codex-app-server-process-teardown.test.ts new file mode 100644 index 00000000000..013ee183d12 --- /dev/null +++ b/src/main/codex/codex-app-server-process-teardown.test.ts @@ -0,0 +1,148 @@ +import type { ChildProcess } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' + +function child() { + return { + pid: 1234, + kill: vi.fn(() => true) as ChildProcess['kill'] + } +} + +describe('terminateCodexAppServerProcessTree', () => { + it('waits for the Windows tree kill before releasing the wrapper', async () => { + const target = child() + const release = Promise.withResolvers() + const terminateWindowsTree = vi.fn(() => release.promise) + + const teardown = terminateCodexAppServerProcessTree(target, undefined, { + platform: 'win32', + terminateWindowsTree + }) + expect(target.kill).not.toHaveBeenCalled() + release.resolve() + await teardown + + expect(terminateWindowsTree).toHaveBeenCalledWith(1234) + expect(target.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills exact Linux spawn-token PIDs before the recorded wrapper', async () => { + const target = child() + const findSpawnTokenProcesses = vi + .fn<() => Promise>() + .mockResolvedValueOnce([1234, 2345, 3456]) + .mockResolvedValueOnce([1234]) + .mockResolvedValueOnce([1234]) + const signalPid = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, 'spawn-1', { + platform: 'linux', + findSpawnTokenProcesses, + signalPid, + isPidPresent: () => false, + wait: async () => undefined + }) + ).resolves.toBe(true) + + expect(signalPid.mock.calls).toEqual([ + [2345, 'SIGKILL'], + [3456, 'SIGKILL'] + ]) + expect(target.kill).toHaveBeenCalledTimes(1) + expect(target.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps the wrapper reachable when Linux cannot prove descendant exit', async () => { + const target = child() + + await expect( + terminateCodexAppServerProcessTree(target, 'spawn-1', { + platform: 'linux', + findSpawnTokenProcesses: async () => null + }) + ).resolves.toBe(false) + + expect(target.kill).not.toHaveBeenCalled() + }) + + it('waits for an owned POSIX snapshot before killing the wrapper', async () => { + const target = child() + const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } + const release = Promise.withResolvers() + + const teardown = terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => snapshot, + terminateDescendants: () => release.promise + }) + await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP')) + expect(target.kill).not.toHaveBeenCalledWith('SIGKILL') + release.resolve(true) + await teardown + + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + }) + + it('signals a proven dedicated POSIX process group without scanning descendants', async () => { + const target = child() + const captureDescendants = vi.fn() + const signalProcessGroup = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + dedicatedProcessGroup: true, + captureDescendants, + signalProcessGroup + }) + ).resolves.toBe(true) + + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') + expect(captureDescendants).not.toHaveBeenCalled() + expect(target.kill).not.toHaveBeenCalled() + }) + + it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => { + const target = child() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'linux', + dedicatedProcessGroup: true, + signalProcessGroup: () => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + ).resolves.toBe(false) + + expect(target.kill).not.toHaveBeenCalled() + }) + + it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { + const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) + const targets = killMocks.map((kill, index) => ({ + pid: 10_000 + index, + kill: kill as ChildProcess['kill'] + })) + const captureDescendants = vi.fn() + const signalProcessGroup = vi.fn() + + const results = await Promise.all( + targets.map((target) => + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'linux', + dedicatedProcessGroup: true, + captureDescendants, + signalProcessGroup + }) + ) + ) + + expect(results).toEqual(Array.from({ length: targets.length }, () => true)) + expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL'])) + expect(captureDescendants).not.toHaveBeenCalled() + expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true) + }) +}) diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts new file mode 100644 index 00000000000..cc7ea8c8d17 --- /dev/null +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -0,0 +1,183 @@ +import type { ChildProcessHandle } from '../../shared/child-process/run-process' +import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' +import { findAgentSessionSpawnTokenProcesses } from '../runtime/agent-session-spawn-token-process-scan' + +const TOKEN_PROCESS_EXIT_TIMEOUT_MS = 3_500 +const TOKEN_PROCESS_POLL_MS = 25 +const activeTeardowns = new WeakMap>() + +type TeardownChild = Pick + +export type CodexAppServerProcessTeardownDeps = { + platform?: NodeJS.Platform + dedicatedProcessGroup?: boolean + /** Diagnostic/recovery injection only; never used by the primary teardown. */ + findSpawnTokenProcesses?: (spawnToken: string) => Promise + captureDescendants?: (rootPid: number) => Promise + terminateDescendants?: (snapshot: DescendantSnapshot) => Promise + terminateWindowsTree?: (rootPid: number) => Promise + signalPid?: (pid: number, signal: NodeJS.Signals) => void + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + isPidPresent?: (pid: number) => boolean + wait?: (ms: number) => Promise + now?: () => number +} + +function terminateDedicatedPosixGroup( + rootPid: number, + deps: CodexAppServerProcessTeardownDeps +): boolean { + const signalGroup = + deps.signalProcessGroup ?? + ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) + try { + signalGroup(rootPid, 'SIGKILL') + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } +} + +function sendSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch { + // An already-gone exact PID is the desired outcome. + } +} + +function isPidPresent(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +async function diagnosticTokenFallback( + rootPid: number, + spawnToken: string, + deps: CodexAppServerProcessTeardownDeps +): Promise { + const find = deps.findSpawnTokenProcesses ?? findAgentSessionSpawnTokenProcesses + const signal = deps.signalPid ?? sendSignal + const pidPresent = deps.isPidPresent ?? isPidPresent + const delay = + deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const now = deps.now ?? Date.now + const deadline = now() + TOKEN_PROCESS_EXIT_TIMEOUT_MS + const signalled = new Set() + while (now() < deadline) { + const pids = await find(spawnToken).catch(() => null) + if (pids === null) { + return false + } + for (const pid of pids.filter((candidate) => candidate !== rootPid)) { + signalled.add(pid) + signal(pid, 'SIGKILL') + } + if ([...signalled].every((pid) => !pidPresent(pid))) { + return true + } + await delay(TOKEN_PROCESS_POLL_MS) + } + return false +} + +async function terminatePosixTree( + child: TeardownChild, + rootPid: number, + _spawnToken: string | undefined, + deps: CodexAppServerProcessTeardownDeps +): Promise { + // Kept only for explicit recovery callers/tests. Production always follows + // the dedicated process-group path below; token enumeration is evidence, + // never the owner of orphan-reaping decisions. + if (_spawnToken && deps.findSpawnTokenProcesses) { + const reaped = await diagnosticTokenFallback(rootPid, _spawnToken, deps) + if (reaped) { + child.kill('SIGKILL') + return true + } + return false + } + child.kill('SIGSTOP') + const capture = deps.captureDescendants ?? captureDescendantSnapshot + const snapshot = await capture(rootPid).catch(() => null) + if (!snapshot) { + child.kill('SIGKILL') + return true + } + const terminate = deps.terminateDescendants ?? terminateDescendantSnapshotAndWait + const descendantsExited = await terminate(snapshot) + // A detached POSIX launch is the leader of its own process group. Group + // signalling reaches grandchildren even after they daemonise/reparent, + // while the stopped root and captured pgid make the ownership proof exact. + // The identity-gated descendant sweep remains the fallback for older hosts + // or launches that could not establish a dedicated group. + if (descendantsExited && snapshot.rootPgid === rootPid) { + const signalGroup = + deps.signalProcessGroup ?? + ((pgid: number, signal: NodeJS.Signals) => { + try { + process.kill(-pgid, signal) + } catch { + // Group already exited. + } + }) + signalGroup(snapshot.rootPgid, 'SIGKILL') + } + if (!descendantsExited) { + child.kill('SIGCONT') + return false + } + child.kill('SIGKILL') + return true +} + +/** Stops every process owned by one app-server launch before releasing its wrapper. */ +async function terminateOnce( + child: TeardownChild, + spawnToken: string | undefined, + deps: CodexAppServerProcessTeardownDeps +): Promise { + const rootPid = child.pid + if (!rootPid) { + child.kill('SIGKILL') + return false + } + if ((deps.platform ?? process.platform) === 'win32') { + const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree + await terminate(rootPid) + // taskkill owns the tree; this preserves the prior direct-child fallback when it fails. + child.kill('SIGKILL') + return true + } + if (deps.dedicatedProcessGroup) { + return terminateDedicatedPosixGroup(rootPid, deps) + } + return terminatePosixTree(child, rootPid, spawnToken, deps) +} + +export function terminateCodexAppServerProcessTree( + child: TeardownChild, + spawnToken?: string, + deps: CodexAppServerProcessTeardownDeps = {} +): Promise { + const key = child as object + const active = activeTeardowns.get(key) + if (active) { + return active + } + const attempt = terminateOnce(child, spawnToken, deps).catch(() => false) + activeTeardowns.set(key, attempt) + void attempt.then(() => { + if (activeTeardowns.get(key) === attempt) { + activeTeardowns.delete(key) + } + }) + return attempt +} diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts new file mode 100644 index 00000000000..0dbefaca965 --- /dev/null +++ b/src/main/codex/codex-app-server-request-error.ts @@ -0,0 +1,15 @@ +/** Codex answered the call and refused it, rather than timing out or exiting. */ +export class CodexAppServerRequestError extends Error { + constructor( + readonly method: string, + readonly code: number | null, + message: string + ) { + super(message) + this.name = 'CodexAppServerRequestError' + } +} + +export function isCodexAppServerRequestError(error: unknown): error is CodexAppServerRequestError { + return error instanceof Error && error.name === 'CodexAppServerRequestError' +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 76e6acf9337..2e176e13ae2 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -100,11 +100,35 @@ export function killCodexAppServerProcessTree( // Fall through to the direct-child best effort when taskkill cannot start. } } + if (child.pid) { + try { + // npm/package-manager launchers insert a shim child on POSIX. Reap its + // direct descendants before signalling the wrapper itself. + const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { + stdio: 'ignore' + }) + // A missing pkill surfaces as an async 'error' event, and an unhandled one + // takes down the main process. + descendants.on('error', () => undefined) + descendants.unref() + } catch { + // The direct kill below remains the fallback when pkill is unavailable. + } + } child.kill('SIGKILL') } -function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { - return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') +/** Codex answering "no such method" is the only response that proves the RPC + * surface is absent rather than temporarily failing. */ +export function isCodexMethodNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + const { code, message } = error as { code?: unknown; message?: unknown } + return ( + code === JSON_RPC_METHOD_NOT_FOUND || + /method not found/i.test(typeof message === 'string' ? message : '') + ) } /** @@ -265,7 +289,7 @@ export async function runCodexAppServerSession( } }) if (response.error) { - if (isMethodNotFoundError(response.error)) { + if (isCodexMethodNotFoundError(response.error)) { throw new CodexAppServerUnsupportedError( `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` ) diff --git a/src/main/codex/codex-app-server-teardown.integration.test.ts b/src/main/codex/codex-app-server-teardown.integration.test.ts new file mode 100644 index 00000000000..c2b09c66444 --- /dev/null +++ b/src/main/codex/codex-app-server-teardown.integration.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { + openCodexAppServerConnection, + type CodexAppServerConnection +} from './codex-app-server-connection' + +const ITERATIONS = 40 + +const FORCE_KILL_APP_SERVER = String.raw` + const { spawn } = require('node:child_process') + const readline = require('node:readline') + const descendant = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 60000)"], { + stdio: 'ignore' + }) + const exitMode = process.env.ORCA_TEST_PROVIDER_EXIT_MODE + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'initialized') { + send({ method: 'test/descendant', params: { pid: descendant.pid } }) + if (exitMode === 'normal' || exitMode === 'stdin-race') { + setTimeout(() => process.exit(0), 25) + } else if (exitMode === 'signal') { + setTimeout(() => process.kill(process.pid, 'SIGTERM'), 25) + } + } + }) + setInterval(() => {}, 60000) +` + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +type RunningServer = { + connection: CodexAppServerConnection + descendantPid: number + exit: Promise + supervisorPid: number +} + +async function openServer( + iteration: number, + exitMode?: 'normal' | 'signal' | 'stdin-race' +): Promise { + const descendant = Promise.withResolvers() + const exit = Promise.withResolvers() + const connection = await openCodexAppServerConnection( + { + command: process.execPath, + args: ['-e', FORCE_KILL_APP_SERVER], + env: { + [CODEX_SPAWN_TOKEN_ENV]: `teardown-test-${process.pid}-${iteration}`, + ...(exitMode ? { ORCA_TEST_PROVIDER_EXIT_MODE: exitMode } : {}) + } + }, + { + onExit: (error) => exit.resolve(error), + onNotification: (method, params) => { + if (method === 'test/descendant') { + descendant.resolve((params as { pid: number }).pid) + } + } + } + ) + return { + connection, + descendantPid: await descendant.promise, + exit: exit.promise, + supervisorPid: connection.pid ?? 0 + } +} + +async function cleanupServer(server: RunningServer): Promise { + await server.connection.close().catch(() => false) + for (const pid of [server.descendantPid, server.supervisorPid]) { + if (pid > 0 && processExists(pid)) { + process.kill(pid, 'SIGKILL') + } + } +} + +describe.runIf(process.platform !== 'win32')('Codex app-server process teardown', () => { + it('reaps the forced-close descendant in 40 consecutive launches', async () => { + const running: RunningServer[] = [] + try { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + running.push(await openServer(iteration)) + } + expect(running.every(({ descendantPid }) => processExists(descendantPid))).toBe(true) + + const closed = await Promise.all(running.map(({ connection }) => connection.close())) + + expect(closed).toEqual(Array.from({ length: ITERATIONS }, () => true)) + expect(running.filter(({ descendantPid }) => processExists(descendantPid))).toEqual([]) + } finally { + for (const server of running) { + await cleanupServer(server) + } + } + }, 30_000) + + it.each(['normal', 'signal'] as const)( + 'reaps provider descendants before relaying a %s root exit in 40 consecutive launches', + async (exitMode) => { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const server = await openServer(iteration, exitMode) + try { + await server.exit + expect(processExists(server.supervisorPid)).toBe(false) + expect(processExists(server.descendantPid)).toBe(false) + await expect(server.connection.close()).resolves.toBe(true) + } finally { + await cleanupServer(server) + } + } + }, + 60_000 + ) + + it('does not settle a stdin-close/root-exit race before the descendant is reaped', async () => { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const server = await openServer(iteration, 'stdin-race') + try { + await expect(server.connection.close()).resolves.toBe(true) + expect(processExists(server.supervisorPid)).toBe(false) + expect(processExists(server.descendantPid)).toBe(false) + } finally { + await cleanupServer(server) + } + } + }, 60_000) +}) diff --git a/src/main/codex/codex-resume-process-proof.test.ts b/src/main/codex/codex-resume-process-proof.test.ts new file mode 100644 index 00000000000..a46e01551ee --- /dev/null +++ b/src/main/codex/codex-resume-process-proof.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + isCodexResumeProcessCommandLine, + readCodexResumeProcessIdentity +} from './codex-resume-process-proof' + +const THREAD_ID = '01a03a0d-acbd-74e0-86f2-2615984d3b37' + +describe('Codex resume process proof', () => { + it('binds the exact resumed thread while ignoring a generic Codex sibling', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { + pid: 101, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex resume ${THREAD_ID}` + }, + { + pid: 103, + ppid: 101, + stat: 'S+', + command: `/opt/codex/vendor/codex resume ${THREAD_ID}` + }, + { + pid: 102, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex --profile work resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], + readStartTime: async () => 1_700_000_000_000, + timeoutMs: 0 + }) + ).resolves.toMatchObject({ pid: 102 }) + }) + + it('rejects the previous owner process tree when no new resume child appears', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex' }, + { + pid: 102, + ppid: 101, + stat: 'S+', + command: `/opt/codex/vendor/codex resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it.each([ + ['another thread', `node /opt/codex/bin/codex resume thread-other`], + ['a generic Codex child', 'node /opt/codex/bin/codex --profile work'] + ])('rejects %s', async (_case, command) => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command } + ], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('accepts an exact resume process after the previous PID was recycled', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { + pid: 101, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: 10 }], + readStartTime: async () => 5_000, + timeoutMs: 0 + }) + ).resolves.toMatchObject({ pid: 101 }) + }) + + it('parses a quoted Windows executable path with the exact resume argv', () => { + expect( + isCodexResumeProcessCommandLine( + `"C:\\Program Files\\Codex\\codex.exe" --profile work resume ${THREAD_ID}`, + THREAD_ID, + 'win32' + ) + ).toBe(true) + }) +}) diff --git a/src/main/codex/codex-resume-process-proof.ts b/src/main/codex/codex-resume-process-proof.ts new file mode 100644 index 00000000000..1c6839bf867 --- /dev/null +++ b/src/main/codex/codex-resume-process-proof.ts @@ -0,0 +1,101 @@ +import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' + +const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024 + +function tokenizeWindowsProcessCommandLine(commandLine: string): string[] { + const tokens: string[] = [] + let current = '' + let quoted = false + let started = false + let index = 0 + while (index < commandLine.length) { + const char = commandLine[index]! + if (!started && /\s/.test(char)) { + index += 1 + continue + } + started = true + if (char === '\\') { + let backslashes = 0 + while (commandLine[index] === '\\') { + backslashes += 1 + index += 1 + } + if (commandLine[index] === '"') { + current += '\\'.repeat(Math.floor(backslashes / 2)) + if (backslashes % 2 === 1) { + current += '"' + index += 1 + } + } else { + current += '\\'.repeat(backslashes) + } + continue + } + if (char === '"') { + if (quoted && commandLine[index + 1] === '"') { + current += '"' + index += 2 + continue + } + quoted = !quoted + index += 1 + continue + } + if (!quoted && /\s/.test(char)) { + tokens.push(current) + current = '' + started = false + index += 1 + continue + } + current += char + index += 1 + } + if (started) { + tokens.push(current) + } + return tokens +} + +function tokenizeProcessCommandLine( + commandLine: string, + platform: NodeJS.Platform +): string[] | null { + if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) { + return null + } + if (platform === 'win32') { + return tokenizeWindowsProcessCommandLine(commandLine) + } + const parsed = tokenizeCustomCommandTemplate(commandLine) + return parsed.ok ? parsed.tokens : null +} + +export function isCodexResumeProcessCommandLine( + commandLine: string, + threadId: string, + platform: NodeJS.Platform = process.platform +): boolean { + const tokens = tokenizeProcessCommandLine(commandLine, platform) + if (!tokens || !threadId) { + return false + } + return tokens.some((token, index) => token === 'resume' && tokens[index + 1] === threadId) +} + +type StructuredTuiIdentityInput = Parameters[0] + +export function readCodexResumeProcessIdentity( + input: Omit & { threadId: string } +): Promise { + const { threadId, ...identityInput } = input + const platform = input.platform ?? process.platform + return readStructuredTuiProcessIdentity({ + ...identityInput, + agent: 'codex', + processCommandMatches: (command) => isCodexResumeProcessCommandLine(command, threadId, platform) + }) +} diff --git a/src/main/codex/codex-server-request-disposition.test.ts b/src/main/codex/codex-server-request-disposition.test.ts new file mode 100644 index 00000000000..c5cf4fafcf2 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_ATTESTATION_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_BLOCKING_SERVER_REQUEST_METHODS, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + disposeCodexServerRequest +} from './codex-server-request-disposition' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry +} from './codex-structured-prompt-replies' + +function harness() { + return { + registry: new CodexPromptRegistry(), + connection: { respond: vi.fn(), respondWithError: vi.fn() } + } +} + +const promptParams = { threadId: 'thread-1', turnId: 'turn-1', itemId: 'item-1' } + +describe('Codex blocking server request dispositions', () => { + it.each([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD + ])('routes %s to the durable prompt registry', (method) => { + const { registry, connection } = harness() + const result = disposeCodexServerRequest(registry, connection, { + id: 1, + method, + params: + method === CODEX_USER_INPUT_METHOD + ? { ...promptParams, questions: [{ id: 'q1' }] } + : promptParams + }) + + expect(result.kind).toBe('prompt') + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).not.toHaveBeenCalled() + }) + + it.each([ + [CODEX_MCP_ELICITATION_METHOD, { action: 'decline', content: null, _meta: null }], + [CODEX_PERMISSIONS_APPROVAL_METHOD, { permissions: {}, scope: 'turn', strictAutoReview: true }], + [CODEX_DYNAMIC_TOOL_CALL_METHOD, { contentItems: [], success: false }], + [CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, { decision: 'abort' }], + [CODEX_LEGACY_EXEC_APPROVAL_METHOD, { decision: 'abort' }] + ])('safely responds to %s', (method, response) => { + const { registry, connection } = harness() + + expect(disposeCodexServerRequest(registry, connection, { id: 2, method, params: {} })).toEqual({ + kind: 'responded', + method + }) + expect(connection.respond).toHaveBeenCalledWith(2, response) + }) + + it.each([ + [CODEX_AUTH_TOKEN_REFRESH_METHOD, 'cannot refresh app-server auth tokens'], + [CODEX_ATTESTATION_METHOD, 'did not negotiate attestation'] + ])('explicitly refuses %s', (method, message) => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { id: 3, method, params: {} }) + + expect(connection.respondWithError).toHaveBeenCalledWith( + 3, + -32001, + expect.stringContaining(message) + ) + }) + + it('cancels a malformed interactive request instead of using method-not-found', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 4, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: {} + }) + + expect(connection.respond).toHaveBeenCalledWith(4, { decision: 'cancel' }) + }) + + it('enumerates every server request in the negotiated stable schema', () => { + expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual( + new Set([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD + ]) + ) + }) + + it('bounds the future-method fallback to one explicit rejection', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 5, + method: 'future/blockingRequest', + params: { opaque: true } + }) + + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).toHaveBeenCalledOnce() + expect(connection.respondWithError).toHaveBeenCalledWith( + 5, + -32000, + 'Orca rejected unrecognized blocking request future/blockingRequest' + ) + }) +}) diff --git a/src/main/codex/codex-server-request-disposition.ts b/src/main/codex/codex-server-request-disposition.ts new file mode 100644 index 00000000000..362a4292de0 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.ts @@ -0,0 +1,86 @@ +import type { + CodexAppServerConnection, + CodexAppServerServerRequest +} from './codex-app-server-connection' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + type CodexPromptRegistry, + type CodexPendingPrompt +} from './codex-structured-prompt-replies' + +export const CODEX_MCP_ELICITATION_METHOD = 'mcpServer/elicitation/request' +export const CODEX_PERMISSIONS_APPROVAL_METHOD = 'item/permissions/requestApproval' +export const CODEX_DYNAMIC_TOOL_CALL_METHOD = 'item/tool/call' +export const CODEX_AUTH_TOKEN_REFRESH_METHOD = 'account/chatgptAuthTokens/refresh' +export const CODEX_ATTESTATION_METHOD = 'attestation/generate' +export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval' +export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval' + +export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD +] as const + +export type CodexServerRequestDisposition = + | { kind: 'prompt'; prompt: CodexPendingPrompt } + | { kind: 'responded'; method: string } + +type ResponseConnection = Pick + +/** Every app-server request either becomes a durable prompt or receives a safe reply. */ +export function disposeCodexServerRequest( + registry: CodexPromptRegistry, + connection: ResponseConnection, + request: CodexAppServerServerRequest +): CodexServerRequestDisposition { + const prompt = registry.register(request) + if (prompt) { + return { kind: 'prompt', prompt } + } + + switch (request.method) { + case CODEX_COMMAND_APPROVAL_METHOD: + case CODEX_FILE_CHANGE_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'cancel' }) + break + case CODEX_USER_INPUT_METHOD: + connection.respond(request.id, { answers: {} }) + break + case CODEX_MCP_ELICITATION_METHOD: + connection.respond(request.id, { action: 'decline', content: null, _meta: null }) + break + case CODEX_PERMISSIONS_APPROVAL_METHOD: + connection.respond(request.id, { permissions: {}, scope: 'turn', strictAutoReview: true }) + break + case CODEX_DYNAMIC_TOOL_CALL_METHOD: + connection.respond(request.id, { contentItems: [], success: false }) + break + case CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD: + case CODEX_LEGACY_EXEC_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'abort' }) + break + case CODEX_AUTH_TOKEN_REFRESH_METHOD: + connection.respondWithError(request.id, -32001, 'Orca cannot refresh app-server auth tokens') + break + case CODEX_ATTESTATION_METHOD: + connection.respondWithError(request.id, -32001, 'Orca did not negotiate attestation') + break + default: + connection.respondWithError( + request.id, + -32000, + `Orca rejected unrecognized blocking request ${request.method}` + ) + } + return { kind: 'responded', method: request.method } +} diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts new file mode 100644 index 00000000000..f0737fc36a3 --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { CodexAppServerConnection } from './codex-app-server-connection-types' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +describe('Codex failed-acquisition exit proof', () => { + it('retains a connection whose handshake cleanup could not prove exit', async () => { + const close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: true, + request: async () => ({}), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const handshakeError = Object.assign(new Error('initialize failed'), { + name: 'CodexAppServerHandshakeExitUnprovenError', + connection + }) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => { + throw handshakeError + }, + readProcessStartTime: async () => 1_700_000_000_000 + }) + + await expect( + adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(close).toHaveBeenCalledTimes(2) + }) + + it('retains an uncommitted child until a later close proves exit', async () => { + const close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => + method === 'thread/resume' + ? { thread: { id: 'thread-1', path: '/rollouts/thread-1.jsonl' } } + : {}, + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => connection, + readProcessStartTime: async () => null + }) + + await expect( + adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(close).toHaveBeenCalledTimes(2) + }) + + it('keeps closeAll blocked by an unproven canceled acquisition', async () => { + const processStart = Promise.withResolvers() + const readStarted = Promise.withResolvers() + const close = vi.fn<() => Promise>().mockResolvedValue(false) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async () => ({ thread: { id: 'thread-1' } }), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => connection, + readProcessStartTime: () => { + readStarted.resolve() + return processStart.promise + } + }) + const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + await readStarted.promise + + await expect(adapter.closeAll()).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + processStart.resolve(null) + await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(close).toHaveBeenCalledTimes(4) + }) + + it('retains a child that opens after closeAll starts when exit remains unproven', async () => { + const openStarted = Promise.withResolvers() + const releaseOpen = Promise.withResolvers() + const close = vi.fn<() => Promise>().mockResolvedValue(false) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async () => ({ thread: { id: 'thread-1' } }), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => { + openStarted.resolve() + await releaseOpen.promise + return connection + }, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + await openStarted.promise + + const closing = adapter.closeAll() + releaseOpen.resolve() + + await expect(closing).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(close).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/main/codex/codex-structured-acquisition-lifecycle.ts b/src/main/codex/codex-structured-acquisition-lifecycle.ts new file mode 100644 index 00000000000..740f95bce2d --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-lifecycle.ts @@ -0,0 +1,53 @@ +import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' +import { + cancelCodexAcquisitionAttempt, + type CodexAcquisitionAttempt, + type CodexAcquisitionRegistry +} from './codex-structured-session-state' + +export async function stopSupersededCodexAcquisition(input: { + sessionId: string + registry: CodexAcquisitionRegistry + replacement: CodexAcquisitionAttempt + previous: CodexAcquisitionAttempt | undefined +}): Promise { + try { + if (!(await cancelCodexAcquisitionAttempt(input.previous))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`codex acquisition for session ${input.sessionId} could not be stopped`) + ) + } + } catch (error) { + if (input.previous) { + input.registry.restoreIfCurrent(input.sessionId, input.replacement, input.previous) + } + throw error + } +} + +export async function closeFailedCodexAcquisition(input: { + sessionId: string + registry: CodexAcquisitionRegistry + attempt: CodexAcquisitionAttempt + cause: unknown + dispose: () => void +}): Promise { + if (isCodexAppServerHandshakeExitUnprovenError(input.cause)) { + input.attempt.window.connection = input.cause.connection + } + input.dispose() + try { + if (!(await input.registry.closeFailedAttempt(input.sessionId, input.attempt))) { + throw new AgentSessionAcquisitionExitUnprovenError(input.cause) + } + } catch (cleanupError) { + if (cleanupError instanceof AgentSessionAcquisitionExitUnprovenError) { + throw cleanupError + } + throw new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([input.cause, cleanupError], 'codex acquisition cleanup failed') + ) + } + throw input.cause +} diff --git a/src/main/codex/codex-structured-acquisition-window.ts b/src/main/codex/codex-structured-acquisition-window.ts new file mode 100644 index 00000000000..8db5534c5d6 --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-window.ts @@ -0,0 +1,33 @@ +// The gap between spawning `codex app-server` and publishing the session it +// belongs to. Codex talks during that gap — the handshake, an early +// notification, even an approval request — and those events belong to the +// session that is still being acquired, so they wait here instead of arriving +// before anything can route them. The gap is bounded by the thread-open request +// timeout; a failed acquisition discards the buffer along with the child. + +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' + +export class CodexAcquisitionWindow { + readonly prompts = new CodexPromptRegistry() + /** Null until the spawn resolves; the handshake can already emit events. */ + connection: CodexAppServerConnection | null = null + private readonly buffered: (() => void)[] = [] + private open = true + + /** Returns false once the session is published, which is the caller's cue to + * deliver live rather than buffer. */ + buffer(event: () => void): boolean { + if (!this.open) { + return false + } + this.buffered.push(event) + return true + } + + /** Closes the window and hands back what arrived while it was open, in order. */ + drain(): (() => void)[] { + this.open = false + return this.buffered.splice(0) + } +} diff --git a/src/main/codex/codex-structured-app-server-args.test.ts b/src/main/codex/codex-structured-app-server-args.test.ts new file mode 100644 index 00000000000..f76305cf7c1 --- /dev/null +++ b/src/main/codex/codex-structured-app-server-args.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args' + +describe('structured Codex app-server arguments', () => { + it('keeps configuration flags and converts effort to the app-server config contract', () => { + expect( + resolveCodexStructuredAppServerArgs( + '--profile review -c approval_policy=never --model gpt-5.6 --effort high --search', + 'posix' + ) + ).toEqual([ + '--profile', + 'review', + '-c', + 'approval_policy=never', + '--model', + 'gpt-5.6', + '-c', + 'model_reasoning_effort=high', + '--search' + ]) + }) + + it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])( + 'reports an incompatible configured argument instead of dropping %s', + (configured) => { + expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow( + /cannot apply the configured CLI arguments.*Settings or use terminal view/ + ) + } + ) +}) diff --git a/src/main/codex/codex-structured-app-server-args.ts b/src/main/codex/codex-structured-app-server-args.ts new file mode 100644 index 00000000000..af83c46c8a2 --- /dev/null +++ b/src/main/codex/codex-structured-app-server-args.ts @@ -0,0 +1,84 @@ +import { + tokenizeStartupCommand, + type AgentStartupShell +} from '../../shared/tui-agent-startup-shell' + +const VALUE_FLAGS = new Set([ + '-a', + '--add-dir', + '--ask-for-approval', + '-c', + '--config', + '--disable', + '--effort', + '--enable', + '--local-provider', + '-m', + '--model', + '-p', + '--profile', + '--reasoning-effort', + '-s', + '--sandbox' +]) + +const BOOLEAN_FLAGS = new Set([ + '--approve-for-me', + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-hook-trust', + '--oss', + '--search', + '--strict-config' +]) + +const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort']) + +function configuredArgsError(detail: string): Error { + return new Error( + `Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.` + ) +} + +function splitOption(token: string): { flag: string; inlineValue?: string } { + const separator = token.indexOf('=') + return separator > 0 + ? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) } + : { flag: token } +} + +/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */ +export function resolveCodexStructuredAppServerArgs( + configuredArgs: string, + shell: AgentStartupShell +): string[] { + const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell) + if (!parsed.ok) { + throw configuredArgsError(parsed.error) + } + const divergent = parsed.spans.find((span) => span.divergesFromShell) + if (divergent) { + throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end)) + } + const result: string[] = [] + for (let index = 0; index < parsed.tokens.length; index += 1) { + const token = parsed.tokens[index] + const { flag, inlineValue } = splitOption(token) + if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) { + result.push(flag) + continue + } + if (!VALUE_FLAGS.has(flag)) { + throw configuredArgsError(token || 'an empty positional argument') + } + const value = inlineValue ?? parsed.tokens[++index] + if (value === undefined || value.length === 0) { + throw configuredArgsError(`${flag} requires a value`) + } + if (EFFORT_FLAGS.has(flag)) { + result.push('-c', `model_reasoning_effort=${value}`) + } else { + result.push(flag, value) + } + } + return result +} diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts new file mode 100644 index 00000000000..98e988ec7d5 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' + +describe('buildCodexStructuredChildEnvironment', () => { + it('keeps shell exports while pinned launch values win', () => { + expect( + buildCodexStructuredChildEnvironment( + { + command: 'codex', + args: ['app-server'], + cwd: '/worktree', + codexHome: '/pinned/home', + resumeThreadId: null, + env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + }, + 'spawn-token' + ) + ).toEqual({ + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/pinned/home', + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token' + }) + }) +}) diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts new file mode 100644 index 00000000000..88326eb3fc0 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.ts @@ -0,0 +1,13 @@ +import type { CodexStructuredLaunch } from './codex-structured-session-state' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' + +export function buildCodexStructuredChildEnvironment( + launch: CodexStructuredLaunch, + spawnToken: string +): Record { + return { + ...launch.env, + ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}), + [CODEX_SPAWN_TOKEN_ENV]: spawnToken + } +} diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts new file mode 100644 index 00000000000..9eb2204b72b --- /dev/null +++ b/src/main/codex/codex-structured-item-streams.ts @@ -0,0 +1,176 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + codexJournalItem, + codexStreamingJournalItem, + type CodexThreadItem +} from './codex-structured-item-translation' + +const CODEX_ITEM_STREAM_TYPES = { + 'item/agentMessage/delta': 'agentMessage', + 'item/plan/delta': 'plan', + 'item/commandExecution/outputDelta': 'commandExecution', + 'item/fileChange/outputDelta': 'fileChange', + 'item/reasoning/summaryTextDelta': 'reasoning', + 'item/reasoning/textDelta': 'reasoning' +} as const + +const PATCH_UPDATED_METHOD = 'item/fileChange/patchUpdated' +const REASONING_PART_METHOD = 'item/reasoning/summaryPartAdded' +const TERMINAL_INTERACTION_METHOD = 'item/commandExecution/terminalInteraction' + +type CodexItemStreamDeps = { + sink: StructuredAgentSessionEventSink + identityFor: ( + threadId: string, + params: unknown, + item: CodexThreadItem + ) => AgentJournalItemIdentity + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +type StreamState = { identity: AgentJournalItemIdentity; item: CodexThreadItem } + +export type CodexStructuredItemStreams = { + track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => void + handle: (threadId: string, method: string, params: unknown) => boolean + forget: (threadId: string, itemId: string) => void + flush: () => void + dispose: () => void +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function codexStructuredItemKey(threadId: string, itemId: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(itemId)}` +} + +export function createCodexStructuredItemStreams( + deps: CodexItemStreamDeps +): CodexStructuredItemStreams { + const states = new Map() + const latestText = new Map() + const checkpointLengths = new Map() + + const append = (state: StreamState, text: string): void => { + const translated = codexStreamingJournalItem(state.item, text) + if (!translated.body) { + return + } + deps.sink.appendItem(state.identity, translated.body, translated.blobs) + deps.sink.publish() + } + + const persist = (key: string, text: string, force: boolean): void => { + latestText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + checkpointLengths.set(key, text.length) + const state = states.get(key) + if (state) { + append(state, text) + } + } + + const coalescer = createAgentSessionDeltaCoalescer({ + windowMs: deps.coalesceMs, + schedule: deps.schedule, + emit: (key, text) => persist(key, text, false) + }) + + const ensureState = ( + threadId: string, + itemId: string, + type: string, + params: unknown + ): StreamState => { + const key = codexStructuredItemKey(threadId, itemId) + const existing = states.get(key) + if (existing) { + return existing + } + const item = { type, id: itemId } + const state = { item, identity: deps.identityFor(threadId, params, item) } + states.set(key, state) + return state + } + + const flush = (): void => { + coalescer.flushAll() + for (const [key, text] of latestText) { + if (checkpointLengths.get(key) !== text.length) { + persist(key, text, true) + } + } + } + + return { + track: (threadId, item, identity) => { + states.set(codexStructuredItemKey(threadId, item.id), { item, identity }) + }, + handle: (threadId, method, params) => { + const paramsRecord = readRecord(params) + const itemId = readString(paramsRecord, 'itemId') + if (method === PATCH_UPDATED_METHOD) { + if (!itemId || !Array.isArray(paramsRecord.changes)) { + return true + } + const key = codexStructuredItemKey(threadId, itemId) + coalescer.flush(key) + const state = ensureState(threadId, itemId, 'fileChange', params) + state.item = { ...state.item, changes: paramsRecord.changes } + const translated = codexJournalItem(state.item) + if (translated.body) { + deps.sink.appendItem(state.identity, translated.body, translated.blobs) + deps.sink.publish() + } + return true + } + if (method === TERMINAL_INTERACTION_METHOD) { + return true + } + const type = CODEX_ITEM_STREAM_TYPES[method as keyof typeof CODEX_ITEM_STREAM_TYPES] + if (!type && method !== REASONING_PART_METHOD) { + return false + } + if (!itemId) { + return true + } + const state = ensureState(threadId, itemId, type ?? 'reasoning', params) + const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta + if (typeof delta === 'string') { + coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta) + } + return true + }, + forget: (threadId, itemId) => { + const key = codexStructuredItemKey(threadId, itemId) + coalescer.forget(key) + states.delete(key) + latestText.delete(key) + checkpointLengths.delete(key) + }, + flush, + dispose: () => { + coalescer.dispose() + states.clear() + latestText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts new file mode 100644 index 00000000000..8895facc11a --- /dev/null +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + codexItemBody, + codexItemIdentity, + codexMessageBlocks, + CodexTurnOrdinals, + isCodexMessageItemType, + readCodexThreadItem, + type CodexThreadItem +} from './codex-structured-item-translation' + +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' + +/** + * Captured from a live `codex app-server` turn: Codex numbers items in arrival + * order and includes the command it ran. + */ +const LIVE_TURN: CodexThreadItem[] = [ + { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list the files' }] }, + { type: 'agentMessage', id: 'item-1', text: 'Let me look.' }, + { + type: 'commandExecution', + id: 'item-2', + command: 'ls', + cwd: '/tmp', + status: 'completed', + exitCode: 0, + aggregatedOutput: 'a\nb\n' + }, + { type: 'agentMessage', id: 'item-3', text: 'Two files.' } +] + +/** + * The SAME turn read back after `thread/resume`: ids are renumbered from 1 and + * the command execution is gone entirely, because Codex does not persist it. + */ +const RESUMED_TURN: CodexThreadItem[] = [ + { type: 'userMessage', id: 'item-1', content: [{ type: 'text', text: 'list the files' }] }, + { type: 'agentMessage', id: 'item-2', text: 'Let me look.' }, + { type: 'agentMessage', id: 'item-3', text: 'Two files.' } +] + +function keysFor(items: CodexThreadItem[]): string[] { + const ordinals = new CodexTurnOrdinals() + return items + .filter((item) => isCodexMessageItemType(item.type)) + .map((item) => + agentJournalItemKey( + codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) + ) + ) +} + +describe('codex turn ordinals', () => { + it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { + const ordinals = new CodexTurnOrdinals() + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) + + ordinals.forgetTurn('thread-1', 'turn-1') + + // A straggler for the released turn — even a previously seen item id — gets + // a FRESH ordinal: reusing a released slot would upsert another item's row. + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(2) + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-3')).toBe(3) + // Other turns are untouched. + expect(ordinals.ordinalFor('thread-1', 'turn-2', 'item-1')).toBe(0) + }) +}) + +describe('codex item identity', () => { + it('gives a resumed turn the same message keys as the live turn it renumbered', () => { + expect(keysFor(LIVE_TURN)).toEqual(keysFor(RESUMED_TURN)) + }) + + it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { + const ordinals = new CodexTurnOrdinals() + const live = LIVE_TURN.map((item) => + codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) + ) + + expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2]) + }) + + it('survives an item type this build does not model without consuming a message ordinal', () => { + const withUnknown = [ + LIVE_TURN[0] as CodexThreadItem, + { type: 'somethingCodexAddedLater', id: 'item-9' }, + LIVE_TURN[1] as CodexThreadItem + ] + + expect(keysFor(withUnknown)).toEqual(keysFor([LIVE_TURN[0], LIVE_TURN[1]] as CodexThreadItem[])) + }) + + it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { + const ordinals = new CodexTurnOrdinals() + ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') + + expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) + expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0) + }) + + it('restarts numbering per turn', () => { + const ordinals = new CodexTurnOrdinals() + ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') + + expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) + }) + + it('keys a non-message item and a turnless message in the orca namespace', () => { + const ordinals = new CodexTurnOrdinals() + const command = codexItemIdentity({ + threadId: THREAD_ID, + turnId: TURN_ID, + item: LIVE_TURN[2] as CodexThreadItem, + ordinals + }) + const orphan = codexItemIdentity({ + threadId: THREAD_ID, + turnId: null, + item: LIVE_TURN[1] as CodexThreadItem, + ordinals + }) + + expect(command).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-2' }) + expect(orphan).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-1' }) + }) +}) + +describe('codex item bodies', () => { + it('reads structured user content and flat agent text alike', () => { + expect(codexMessageBlocks(LIVE_TURN[0] as CodexThreadItem)).toEqual([ + { type: 'text', text: 'list the files' } + ]) + expect(codexMessageBlocks(LIVE_TURN[1] as CodexThreadItem)).toEqual([ + { type: 'text', text: 'Let me look.' } + ]) + }) + + it('keeps provider image echoes in mixed user content', () => { + expect( + codexMessageBlocks({ + type: 'userMessage', + id: 'm', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', url: 'https://example.test/a.png' }, + { type: 'localImage', path: '/tmp/a.png' } + ] + }) + ).toEqual([ + { type: 'text', text: 'look' }, + { type: 'image-ref', url: 'https://example.test/a.png' }, + { type: 'image-ref', path: '/tmp/a.png' } + ]) + }) + + it('maps a finished zero-exit command to a completed shell tool call', () => { + expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({ + kind: 'tool-call', + name: 'shell', + input: { command: 'ls', cwd: '/tmp' }, + state: 'completed', + output: { head: 'a\nb\n', byteLength: 4, truncated: false, digest: expect.any(String) } + }) + }) + + it('calls a nonzero exit a failure even though codex calls the status completed', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-2', + command: 'false', + status: 'completed', + exitCode: 1 + }) + + expect(body).toMatchObject({ state: 'failed' }) + }) + + it('treats an unfinished command as running and an aborted one as failed', () => { + expect( + codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'inProgress' }) + ).toMatchObject({ state: 'running' }) + expect( + codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'aborted' }) + ).toMatchObject({ state: 'failed' }) + }) + + it('maps file changes to one bounded diff item', () => { + expect( + codexItemBody({ + type: 'fileChange', + id: 'patch-1', + status: 'completed', + changes: [ + { path: 'src/a.ts', diff: '@@ a @@' }, + { path: 'src/b.ts', diff: '@@ b @@' } + ] + }) + ).toMatchObject({ + kind: 'diff', + path: '2 files', + patch: { head: '@@ a @@\n@@ b @@', truncated: false } + }) + }) + + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { + expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ + kind: 'status', + text: 'thinking' + }) + expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() + expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() + expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({ + kind: 'status', + text: 'codex · item:webSearch', + providerFrame: { provider: 'codex', kind: 'item:webSearch' } + }) + }) + + it('renders array-shaped reasoning content', () => { + expect( + codexItemBody({ + type: 'reasoning', + id: 'r', + summary: ['first', 'second'], + content: [{ text: 'fallback' }] + }) + ).toEqual({ kind: 'status', text: 'first\nsecond' }) + }) + + it('refuses a value that is not a thread item at all', () => { + expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull() + expect(readCodexThreadItem(null)).toBeNull() + expect(readCodexThreadItem({ type: 'agentMessage', id: 'm' })).not.toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts new file mode 100644 index 00000000000..9269c952eb4 --- /dev/null +++ b/src/main/codex/codex-structured-item-translation.ts @@ -0,0 +1,321 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' + +// Codex thread items → journal item bodies and durable identities. +// +// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers +// item ids positionally on resume (`item-1`…`item-N` across the whole thread), +// and a resumed turn does NOT contain every item the live turn emitted — +// reasoning and command execution are dropped from persisted history. Numbering +// by live position would therefore shift every message after the first tool +// call and hand the user a duplicate of the assistant's answer after a resume. +// +// So the ordinal counts MESSAGE items only, and the same projection is applied +// to the live stream and to a resumed turn's item list. Any other item type — +// including ones this build does not model — is skipped identically on both +// sides, which is what makes the key survive a Codex release that adds one. + +/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ +const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) + +export type CodexThreadItem = { + type: string + id: string + [key: string]: unknown +} + +export function isCodexMessageItemType(type: string): boolean { + return CODEX_MESSAGE_ITEM_TYPES.has(type) +} + +export function readCodexThreadItem(value: unknown): CodexThreadItem | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + return typeof record.type === 'string' && typeof record.id === 'string' + ? (record as CodexThreadItem) + : null +} + +/** + * Ordinals for one thread, assigned on first sight and never reassigned. + * + * Non-message items are given no ordinal at all rather than a number from a + * second counter: a counter that a resumed history cannot reproduce is worse + * than no key, because it would look reconcilable and reconcile wrongly. + */ +export class CodexTurnOrdinals { + private readonly turns = new Map; next: number }>() + + ordinalFor(threadId: string, turnId: string, codexItemId: string): number { + const turnKey = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` + let turn = this.turns.get(turnKey) + if (!turn) { + turn = { assigned: new Map(), next: 0 } + this.turns.set(turnKey, turn) + } + const existing = turn.assigned.get(codexItemId) + if (existing !== undefined) { + return existing + } + const ordinal = turn.next + turn.assigned.set(codexItemId, ordinal) + turn.next += 1 + return ordinal + } + + /** Releases a finished turn's per-item map while keeping its counter, so a + * straggler frame can never be assigned an ordinal the turn already used — + * a reused slot would upsert another item's journal row. */ + forgetTurn(threadId: string, turnId: string): void { + const turn = this.turns.get(`${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`) + if (turn) { + turn.assigned = new Map() + } + } +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +/** + * Durable identity for a Codex item, or null for one that has none. + * + * Non-message items fall back to the `orca` namespace keyed by the Codex item + * id. That id is unstable across resume, so those rows are live-session detail + * that a recovered journal simply will not contain — which is correct: Codex + * itself does not persist them either. + */ +export function codexItemIdentity(input: { + threadId: string + turnId: string | null + item: CodexThreadItem + ordinals: CodexTurnOrdinals +}): AgentJournalItemIdentity { + const { item, turnId } = input + if (turnId && isCodexMessageItemType(item.type)) { + return { + provider: 'codex', + threadId: input.threadId, + turnId, + ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) + } + } + return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readTextContent(source: Record, key: string): string | null { + const direct = readString(source, key) + if (direct) { + return direct + } + const value = source[key] + if (!Array.isArray(value)) { + return null + } + const parts = value.flatMap((part) => { + if (typeof part === 'string') { + return part.length > 0 ? [part] : [] + } + if (typeof part !== 'object' || part === null) { + return [] + } + const text = readString(part as Record, 'text') + return text ? [text] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +/** `userMessage` carries structured content parts; `agentMessage` a flat text. */ +export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { + const text = readString(item, 'text') + if (text !== null) { + return [{ type: 'text', text }] + } + const content = item.content + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const part of content) { + if (typeof part !== 'object' || part === null) { + continue + } + const partText = readString(part as Record, 'text') + if (partText !== null) { + blocks.push({ type: 'text', text: partText }) + continue + } + const record = part as Record + if (record.type === 'image' && typeof record.url === 'string') { + blocks.push({ type: 'image-ref', url: record.url }) + } else if (record.type === 'localImage' && typeof record.path === 'string') { + blocks.push({ type: 'image-ref', path: record.path }) + } + } + return blocks +} + +/** Codex reports `inProgress` then a terminal status; a zero exit code is the + * only thing that makes a finished command a success. */ +function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { + const status = readString(item, 'status') + if (status === null || status === 'inProgress') { + return 'running' + } + if (status !== 'completed') { + return 'failed' + } + const exitCode = item.exitCode + return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' +} + +export type CodexJournalItem = { + body: AgentJournalItemBody | null + blobs: { digest: string; payload: string }[] + handled: boolean +} + +function commandItem(item: CodexThreadItem): CodexJournalItem { + const output = readString(item, 'aggregatedOutput') + const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: 'shell', + input: { command: item.command ?? null, cwd: item.cwd ?? null }, + state: commandState(item), + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + blobs: + output !== null && bounded?.bounded.truncated + ? [{ digest: bounded.bounded.digest, payload: output }] + : [], + handled: true + } +} + +function fileChangeItem(item: CodexThreadItem): CodexJournalItem { + const changes = Array.isArray(item.changes) + ? item.changes.flatMap((change) => { + const record = typeof change === 'object' && change !== null ? readRecord(change) : {} + const path = readString(record, 'path') + const diff = readString(record, 'diff') + return path && diff ? [{ path, diff }] : [] + }) + : [] + if (changes.length === 0) { + return { + body: { + kind: 'tool-call', + name: 'apply_patch', + input: { changes: item.changes ?? null }, + state: commandState(item) + }, + blobs: [], + handled: true + } + } + const patch = changes.map((change) => change.diff).join('\n') + const bounded = boundInlineText(patch, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded + return { + body: { + kind: 'diff', + path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`, + patch: bounded + }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: patch }] : [], + handled: true + } +} + +/** + * Journal body for a Codex item, or null for one with nothing to render. + * + * Known empty items wait for later deltas. Unknown types become bounded status + * rows so a provider release cannot make new activity invisible. + */ +export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { + if (item.type === 'userMessage' || item.type === 'agentMessage') { + const blocks = codexMessageBlocks(item) + return { + body: + blocks.length === 0 + ? null + : { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks }, + blobs: [], + handled: true + } + } + if (item.type === 'commandExecution') { + return commandItem(item) + } + if (item.type === 'fileChange') { + return fileChangeItem(item) + } + if (item.type === 'reasoning' || item.type === 'plan') { + const text = + readTextContent(item, 'text') ?? + readTextContent(item, 'summary') ?? + readTextContent(item, 'content') + return { + body: + text === null + ? null + : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + blobs: [], + handled: true + } + } + const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item) + return unhandled + ? { body: unhandled.body, blobs: unhandled.blobs, handled: false } + : { body: null, blobs: [], handled: true } +} + +export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null { + return codexJournalItem(item).body +} + +/** Snapshot body for text still streaming, before its item completes. */ +export function codexStreamingMessageBody(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +/** Snapshot body for any item-level stream, keyed onto its parent item. */ +export function codexStreamingJournalItem(item: CodexThreadItem, text: string): CodexJournalItem { + if (item.type === 'agentMessage') { + return { body: codexStreamingMessageBody(text), blobs: [], handled: true } + } + if (item.type === 'commandExecution') { + return commandItem({ ...item, aggregatedOutput: text }) + } + if (item.type === 'fileChange') { + const path = Array.isArray(item.changes) + ? readString(readRecord(item.changes[0]), 'path') + : null + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded + return { + body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: text }] : [], + handled: true + } + } + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { body: { kind: 'status', text: bounded.text }, blobs: [], handled: true } +} diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts new file mode 100644 index 00000000000..84497c50c79 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -0,0 +1,785 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + projectStructuredAgentSessionStatus, + projectStructuredItemsToNativeChat +} from '../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexTurnOrdinals } from './codex-structured-item-translation' +import { + createCodexJournalTranslator, + MAX_CODEX_GENERIC_ROWS_PER_TURN +} from './codex-structured-journal-translation' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD +} from './codex-structured-prompt-replies' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' + +type Row = { key: string; body: AgentJournalItemBody } + +function recorder() { + const rows: Row[] = [] + const tombstones: string[] = [] + const bound: [string, string, string][] = [] + let publishes = 0 + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: (identity) => tombstones.push(agentJournalItemKey(identity)), + publish: () => { + publishes += 1 + } + } + return { + sink, + rows, + tombstones, + bound, + publishes: () => publishes, + bindPromptItemId: (journalItemId: string, threadId: string, promptKey: string) => + bound.push([journalItemId, threadId, promptKey]) + } +} + +/** Fires the coalescing window on demand instead of on wall time. */ +function manualWindow() { + let pending: (() => void) | null = null + return { + schedule: (run: () => void) => { + pending = run + return () => { + pending = null + } + }, + fire: () => { + const run = pending + pending = null + run?.() + }, + idle: () => pending === null + } +} + +function notification(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params } +} + +const TURN_STARTED = notification('turn/started', { turn: { id: TURN_ID } }) + +function translatorWith(tap = recorder(), window = manualWindow()) { + const translator = createCodexJournalTranslator({ + sink: tap.sink, + bindPromptItemId: tap.bindPromptItemId, + schedule: window.schedule + }) + return { translator, tap, window } +} + +describe('codex journal translation', () => { + it('projects turns restored by thread/resume into durable conversation rows', () => { + const { translator, tap } = translatorWith() + + translator.restoreThread(THREAD_ID, { + turns: [ + { + id: 'turn-restored', + items: [ + { + type: 'userMessage', + id: 'user-restored', + content: [{ type: 'text', text: 'existing question' }] + }, + { type: 'agentMessage', id: 'agent-restored', text: 'existing answer' } + ] + } + ] + }) + + expect(tap.rows.map((row) => row.body)).toEqual([ + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'existing question' }] + }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'existing answer' }] + } + ]) + }) + + it('durably opens and closes the primary turn cancellation lifecycle', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(TURN_STARTED) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + + expect(tap.rows).toEqual([ + { + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1', + body: { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId: TURN_ID, state: 'running' } + } + } + ]) + expect(tap.tombstones).toEqual(['legacy:codex:session-1:turn-lifecycle%3Aturn-1']) + }) + + it('closes every active turn when the provider session ends after a later turn starts', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/started', { turn: { id: 'turn-later' } })) + translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' }) + + expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(2) + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnLifecycle: { turnId: 'turn-stale', state: 'running' } }), + expect.objectContaining({ turnLifecycle: { turnId: 'turn-later', state: 'running' } }) + ]) + expect(tap.tombstones).toEqual([ + 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', + 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' + ]) + // The tombstones remove both running rows from the reduced journal; no + // lifecycle identity remains live after a session end. + expect( + projectStructuredAgentSessionStatus( + tap.rows + .filter((row) => !tap.tombstones.includes(row.key)) + .map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) + ) + ).toBe('idle') + }) + + it('matches out-of-order completions to each turn identity', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/started', { turn: { id: 'turn-later' } })) + translator.handle(notification('turn/completed', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/completed', { turn: { id: 'turn-later' } })) + + expect(tap.tombstones).toEqual([ + 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', + 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' + ]) + expect( + projectStructuredAgentSessionStatus( + tap.rows + .filter((row) => !tap.tombstones.includes(row.key)) + .map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) + ) + ).toBe('idle') + }) + + it('journals a user turn and the assistant answer under durable codex keys', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + }) + ) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'hello' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-abc:turn-1:1' + ]) + expect(tap.rows[1]?.body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'hello' }] + }) + }) + + it('folds streamed deltas into one snapshot row on the same key the item started under', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'he' })) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'llo' })) + window.fire() + + // `item/started` had no text to journal; only the coalesced snapshot lands. + expect(tap.rows).toEqual([ + { + key: 'codex:thread-abc:turn-1:0', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + } + ]) + }) + + it('upserts the streamed text and the completed body onto one row, body last', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'part' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'partial' } + }) + ) + window.fire() + + // One key, so the reducer keeps the last write; the stale snapshot cannot + // come back after the window it was pending on fires. + expect(new Set(tap.rows.map((row) => row.key))).toEqual(new Set(['codex:thread-abc:turn-1:0'])) + expect(tap.rows.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'part' }] }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'partial' }] } + ]) + }) + + it('flushes pending text before a lifecycle event, so nothing is journaled ahead of it', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'text' })) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-2' + ]) + }) + + it('flushes what streamed when the child dies unannounced', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'half' })) + translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' }) + + expect(tap.rows.at(-1)?.body).toMatchObject({ blocks: [{ type: 'text', text: 'half' }] }) + expect(window.idle()).toBe(true) + }) + + it('journals an approval naming the command the item already announced, and binds it', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { + type: 'commandExecution', + id: 'item-2', + command: 'rm -rf build', + status: 'inProgress' + } + }) + ) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept', 'decline'] }, + codexItemId: 'item-2', + promptKey: 'item-2' + }) + + const approval = tap.rows.at(-1) + expect(approval?.key).toBe('orca:codex-prompt%3Athread-abc%3Aitem-2') + expect(approval?.body).toMatchObject({ kind: 'approval', detail: 'rm -rf build' }) + expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', THREAD_ID, 'item-2']]) + }) + + it('journals one row per approval when a tool item asks twice', () => { + const { translator, tap } = translatorWith() + const ask = (promptKey: string): void => { + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept', 'decline'] }, + codexItemId: 'item-2', + promptKey + }) + } + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' } + }) + ) + ask('approval-a') + ask('approval-b') + + // Two asks, two answerable rows — keying by the tool item would have made the + // second ask overwrite the first, leaving the turn blocked. + const approvals = tap.rows.slice(-2) + expect(approvals.map((row) => row.key)).toEqual([ + 'orca:codex-prompt%3Athread-abc%3Aapproval-a', + 'orca:codex-prompt%3Athread-abc%3Aapproval-b' + ]) + // Both still name the command the shared item announced. + expect(approvals.every((row) => (row.body as { detail?: string }).detail === 'ls')).toBe(true) + expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b']) + }) + + it('journals and binds one row per question in a user-input request', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_USER_INPUT_METHOD, + params: { + questions: [ + { id: 'q1', question: 'Which branch?', options: [{ label: 'main' }] }, + { id: 'q2', question: 'Proceed?', options: [{ label: 'yes' }] } + ] + }, + codexItemId: 'item-3', + promptKey: 'item-3' + }) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq1', + 'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq2' + ]) + expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['item-3', 'item-3']) + }) + + it('starts a new turn at ordinal zero and refuses to adopt an ended turn', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } }) + ) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'orphan' } + }) + ) + translator.handle(notification('turn/started', { turn: { id: 'turn-2' } })) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-1', + 'codex:thread-abc:turn-2:0' + ]) + }) + + it('prefers a turn id the event carries over the turn currently open', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + turnId: 'turn-9', + item: { type: 'userMessage', id: 'item-0', text: 'late' } + }) + ) + + expect(tap.rows[0]?.key).toBe('codex:thread-abc:turn-9:0') + }) + + it('keeps interleaved thread turns, items, and deltas separate', () => { + const { translator, tap } = translatorWith() + const child = (method: string, params: unknown): CodexStructuredSessionEvent => ({ + type: 'notification', + sessionId: SESSION_ID, + threadId: 'thread-child', + method, + params + }) + + translator.handle(TURN_STARTED) + translator.handle(child('turn/started', { threadId: 'thread-child', turnId: 'turn-child' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-0', text: 'root' } + }) + ) + translator.handle( + child('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'child' } }) + ) + translator.handle(child('turn/completed', { turnId: 'turn-child' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'still root' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-child:turn-child:0', + 'codex:thread-abc:turn-1:1' + ]) + }) + + it('checkpoints long streams geometrically and flushes the final snapshot', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + + for (let index = 0; index < 512; index += 1) { + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'x' })) + window.fire() + } + translator.flush() + + expect(tap.rows.length).toBeLessThan(40) + expect(tap.rows.at(-1)?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'x'.repeat(512) }] + }) + }) + + it('folds long-running command output into one exec item and zero generic rows', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'exec-1', command: 'long-task', status: 'inProgress' } + }) + ) + + for (let index = 0; index < 512; index += 1) { + translator.handle( + notification('item/commandExecution/outputDelta', { itemId: 'exec-1', delta: 'x' }) + ) + window.fire() + } + translator.flush() + + expect(new Set(tap.rows.map((row) => row.key))).toEqual( + new Set(['orca:codex-item%3Athread-abc%3Aexec-1']) + ) + expect(tap.rows.every((row) => row.body.kind === 'tool-call')).toBe(true) + expect(tap.rows.length).toBeLessThan(40) + expect(tap.rows.at(-1)?.body).toMatchObject({ + kind: 'tool-call', + output: { head: 'x'.repeat(512) } + }) + }) + + it('folds reasoning and patch streams into their parent rows', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle(notification('item/started', { item: { type: 'reasoning', id: 'r-1' } })) + translator.handle( + notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: 'thinking' }) + ) + translator.handle( + notification('item/started', { + item: { type: 'fileChange', id: 'patch-1', changes: [], status: 'inProgress' } + }) + ) + translator.handle( + notification('item/fileChange/patchUpdated', { + itemId: 'patch-1', + changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: '@@ -1 +1 @@' }] + }) + ) + window.fire() + + const reduced = new Map(tap.rows.map((row) => [row.key, row.body])) + expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ + kind: 'status', + text: 'thinking' + }) + expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ + kind: 'diff', + path: 'src/app.ts', + patch: { head: '@@ -1 +1 @@' } + }) + }) + + it('publishes after every write so a subscriber never trails the journal', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } }) + ) + + expect(tap.publishes()).toBe(1) + }) + + it('releases a turn ordinal map when the turn completes', () => { + const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') + try { + const { translator } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + expect(spy).toHaveBeenCalledWith(THREAD_ID, TURN_ID) + } finally { + spy.mockRestore() + } + }) + + it('journals malformed item events but never malformed deltas', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle(notification('item/completed', {})) + translator.handle(notification('item/agentMessage/delta', { delta: 'orphan' })) + window.fire() + + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'notification:item/completed' }) + }) + ]) + }) + + it('journals unknown notifications, server requests, and decoded provider frames', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('future/notification', { value: 1 })) + translator.handle({ + type: 'server-request', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: 'future/request', + params: { value: 2 } + }) + translator.handle({ + type: 'provider-frame', + sessionId: SESSION_ID, + threadId: THREAD_ID, + kind: 'frame:unclassified', + payload: { value: 3 } + }) + + expect( + tap.rows.map((row) => (row.body.kind === 'status' ? row.body.providerFrame?.kind : undefined)) + ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) + }) + + it('bounds generic rows per turn while keeping the suppression visible and countable', () => { + const { translator, tap } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 20; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + translator.handle(notification('item/future/outputDelta', { itemId: 'future', delta: 'x' })) + + const generic = tap.rows.filter( + (row) => row.body.kind === 'status' && row.body.providerFrame !== undefined + ) + expect(generic).toHaveLength(MAX_CODEX_GENERIC_ROWS_PER_TURN) + expect(generic[0]?.body).toMatchObject({ + kind: 'status', + providerFrame: { kind: 'notification:future/notification' } + }) + // The 20 capped frames reduce to ONE summary row whose count is exact, so + // suppressed provider activity is never invisible. + const summaries = new Map( + tap.rows + .filter((row) => row.key.includes('provider-frame-suppressed')) + .map((row) => [row.key, row.body]) + ) + expect(summaries.size).toBe(1) + expect([...summaries.values()][0]).toEqual({ + kind: 'status', + text: '20 more provider notifications not shown for this turn' + }) + expect( + tap.rows.some( + (row) => + row.body.kind === 'status' && + row.body.providerFrame?.kind === 'notification:item/future/outputDelta' + ) + ).toBe(false) + }) + + it('never lets the generic-row cap hide an error frame', () => { + const { translator, tap } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 3; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + translator.handle(notification('future/failure', { error: 'provider exploded' })) + + expect( + tap.rows.some( + (row) => + row.body.kind === 'status' && + row.body.providerFrame?.kind === 'notification:future/failure' + ) + ).toBe(true) + }) + + it('keeps a fresh session timeline empty through startup and status notifications', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('thread/started', { thread: { id: THREAD_ID } })) + for (let index = 0; index < 8; index += 1) { + translator.handle( + notification('mcpServer/startupStatus/updated', { + server: `server-${index}`, + status: 'starting' + }) + ) + } + translator.handle(notification('remoteControl/status/changed', { status: 'disabled' })) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline).toEqual([]) + }) + + it('projects only user and assistant content for a complete turn with hooks', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('thread/started', { thread: { id: THREAD_ID } })) + translator.handle(notification('hook/started', { run: { id: 'hook-1', status: 'running' } })) + translator.handle(notification('account/rateLimits/updated', { rateLimits: { primary: null } })) + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + item: { type: 'userMessage', id: 'item-0', text: 'hi' } + }) + ) + translator.handle( + notification('hook/completed', { run: { id: 'hook-1', status: 'completed' } }) + ) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'hello' } + }) + ) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([ + { role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + ]) + }) + + it('renders a system error carried by a suppressed status kind', () => { + const { translator, tap } = translatorWith() + + translator.handle( + notification('thread/status/changed', { + threadId: THREAD_ID, + status: { type: 'systemError' } + }) + ) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline).toEqual([ + expect.objectContaining({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ + kind: 'notification:thread/status/changed' + }) + }) + ] + }) + ]) + }) + + it('writes nothing more after dispose', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'gone' })) + translator.dispose() + window.fire() + + expect(tap.rows).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts new file mode 100644 index 00000000000..10bfcb9ef98 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -0,0 +1,335 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { + codexItemIdentity, + codexJournalItem, + CodexTurnOrdinals, + readCodexThreadItem +} from './codex-structured-item-translation' +import { + codexStructuredItemKey, + createCodexStructuredItemStreams +} from './codex-structured-item-streams' +import { + codexApprovalItem, + codexPromptIdentity, + codexQuestionItems +} from './codex-structured-prompt-items' +import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' +import { readCodexTurnId } from './codex-structured-thread-facts' + +// The one place Codex events become journal rows. +// +// Every durable decision lives here rather than in the adapter: the adapter +// knows the protocol, this knows what a user is owed after a reconnect. It is +// per-session and per-acquisition — a new lease gets a new translator and a new +// sink, so a superseded child cannot keep writing. + +export const MAX_CODEX_GENERIC_ROWS_PER_TURN = 8 + +export type CodexJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + /** Points an answered journal item back at the live Codex request. */ + bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void + primaryThreadId?: () => string | null + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type CodexJournalTranslator = { + handle: (event: CodexStructuredSessionEvent) => void + restoreThread: (threadId: string, thread: Record) => void + flush: () => void + dispose: () => void +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function createCodexJournalTranslator( + deps: CodexJournalTranslatorDeps +): CodexJournalTranslator { + const ordinals = new CodexTurnOrdinals() + /** Identity assigned when an item was announced, reused by its deltas and by + * its completion so all three upsert one row. */ + const identities = new Map() + /** What each announced item is, so an approval can name what it approves. */ + const details = new Map() + /** Turns announced by the provider and not yet closed. */ + const currentTurnIds = new Map>() + const genericRowsByTurn = new Map() + const suppressedRowsByTurn = new Map() + let fallbackSequence = 0 + + const currentTurnIdFor = (threadId: string): string | null => + [...(currentTurnIds.get(threadId) ?? [])].at(-1) ?? null + + const rememberTurn = (threadId: string, turnId: string): void => { + currentTurnIds.set(threadId, new Set([...(currentTurnIds.get(threadId) ?? []), turnId])) + } + + const forgetTurn = (threadId: string, turnId: string): void => { + const active = currentTurnIds.get(threadId) + active?.delete(turnId) + if (!active?.size) { + currentTurnIds.delete(threadId) + } + } + + const appendUnhandled = (kind: string, payload: unknown, threadId = 'session'): void => { + const translated = unhandledProviderFrameJournalItem('codex', kind, payload) + if (!translated) { + return + } + const turnId = readCodexTurnId(payload) ?? currentTurnIdFor(threadId) ?? 'outside-turn' + const bucket = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` + const rowCount = genericRowsByTurn.get(bucket) ?? 0 + // The cap bounds noise, never evidence: an error frame is always journaled, + // and capped frames stay countable through one summary row per turn. + const capped = + rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN && translated.classification !== 'error-surface' + if (capped) { + const suppressed = (suppressedRowsByTurn.get(bucket) ?? 0) + 1 + suppressedRowsByTurn.set(bucket, suppressed) + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` }, + { + kind: 'status', + text: `${suppressed} more provider notification${suppressed === 1 ? '' : 's'} not shown for this turn` + } + ) + deps.sink.publish() + return + } + genericRowsByTurn.set(bucket, rowCount + 1) + fallbackSequence += 1 + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` }, + translated.body, + translated.blobs + ) + deps.sink.publish() + } + + const publishTurnLifecycle = ( + sessionId: string, + threadId: string, + turnId: string, + state: 'running' | 'completed' + ): void => { + if (deps.primaryThreadId?.() !== threadId) { + return + } + const identity = { + provider: 'legacy' as const, + agent: 'codex' as const, + sessionId, + recordId: `turn-lifecycle:${turnId}` + } + if (state === 'completed') { + deps.sink.appendTombstone(identity) + } else { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId, state } + }) + } + deps.sink.publish() + } + + const identityFor = ( + threadId: string, + turnId: string | null, + item: { type: string; id: string } + ): AgentJournalItemIdentity => { + const key = codexStructuredItemKey(threadId, item.id) + const existing = identities.get(key) + if (existing) { + return existing + } + const identity = codexItemIdentity({ threadId, turnId, item, ordinals }) + identities.set(key, identity) + return identity + } + + const streams = createCodexStructuredItemStreams({ + sink: deps.sink, + coalesceMs: deps.coalesceMs, + schedule: deps.schedule, + identityFor: (threadId, params, item) => { + const turnId = readCodexTurnId(params) ?? currentTurnIdFor(threadId) + return identityFor(threadId, turnId, item) + } + }) + + const handleItemEvent = (event: { + threadId: string + method: string + params: unknown + }): boolean => { + const params = readRecord(event.params) + const item = readCodexThreadItem(params.item) + if (!item) { + return false + } + const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId) + const identity = identityFor(event.threadId, turnId, item) + const translated = codexJournalItem(item) + const command = readString(item, 'command') + if (command) { + details.set(codexStructuredItemKey(event.threadId, item.id), command) + } + if (event.method === 'item/completed') { + // The completed body is authoritative; the coalesced text is now stale. + streams.forget(event.threadId, item.id) + } else { + streams.track(event.threadId, item, identity) + } + if (!translated.body) { + return true + } + deps.sink.appendItem(identity, translated.body, translated.blobs) + deps.sink.publish() + return true + } + + // The row is keyed by the prompt and the announced command is looked up by the + // tool item, because one item can ask more than once. + const handlePrompt = (event: { + threadId: string + method: string + params: unknown + codexItemId: string + promptKey: string + }): void => { + if (event.method === CODEX_USER_INPUT_METHOD) { + for (const question of codexQuestionItems({ + threadId: event.threadId, + promptKey: event.promptKey, + params: event.params + })) { + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.( + agentJournalItemKey(question.identity), + event.threadId, + event.promptKey + ) + } + deps.sink.publish() + return + } + const identity = codexPromptIdentity({ + threadId: event.threadId, + promptKey: event.promptKey + }) + deps.sink.appendItem( + identity, + codexApprovalItem({ + method: event.method, + params: event.params, + detail: details.get(codexStructuredItemKey(event.threadId, event.codexItemId)) ?? null + }) + ) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.threadId, event.promptKey) + deps.sink.publish() + } + + return { + restoreThread: (threadId, thread) => { + const turns = Array.isArray(thread.turns) ? thread.turns : [] + for (const rawTurn of turns) { + const turn = readRecord(rawTurn) + const turnId = readString(turn, 'id') + if (!turnId) { + continue + } + currentTurnIds.set(threadId, new Set([turnId])) + for (const item of Array.isArray(turn.items) ? turn.items : []) { + handleItemEvent({ threadId, method: 'item/completed', params: { turnId, item } }) + } + currentTurnIds.delete(threadId) + ordinals.forgetTurn(threadId, turnId) + } + streams.flush() + }, + handle: (event) => { + if (event.type === 'ended') { + streams.flush() + for (const [threadId, turnIds] of currentTurnIds) { + for (const turnId of turnIds) { + publishTurnLifecycle(event.sessionId, threadId, turnId, 'completed') + ordinals.forgetTurn(threadId, turnId) + } + } + currentTurnIds.clear() + return + } + if ( + event.type === 'notification' && + streams.handle(event.threadId, event.method, event.params) + ) { + return + } + // Lifecycle bypass: nothing may be journaled ahead of the text it follows. + streams.flush() + if (event.type === 'prompt') { + handlePrompt(event) + return + } + if (event.type === 'server-request') { + appendUnhandled(`request:${event.method}`, event.params, event.threadId) + return + } + if (event.type === 'provider-frame') { + appendUnhandled(event.kind, event.payload, event.threadId) + return + } + if (event.method === 'turn/started') { + const turnId = readCodexTurnId(event.params) + if (turnId) { + rememberTurn(event.threadId, turnId) + publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'running') + } + return + } + if (event.method === 'turn/completed') { + const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId) + if (turnId) { + publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'completed') + ordinals.forgetTurn(event.threadId, turnId) + forgetTurn(event.threadId, turnId) + } + // A later item without its own turn id falls back to another active + // turn, if one exists; completed turns are never adopted again. + return + } + if (event.method === 'item/started' || event.method === 'item/completed') { + if (!handleItemEvent(event)) { + appendUnhandled(`notification:${event.method}`, event.params, event.threadId) + } + return + } + appendUnhandled(`notification:${event.method}`, event.params, event.threadId) + }, + flush: streams.flush, + dispose: () => { + streams.dispose() + identities.clear() + details.clear() + currentTurnIds.clear() + genericRowsByTurn.clear() + suppressedRowsByTurn.clear() + } + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts new file mode 100644 index 00000000000..484f7c1ee80 --- /dev/null +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' + +const SESSION_ID = 'session-1' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'codex', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' }, + providerHandleChain: [], + ...overrides + } as AgentSessionRecord +} + +function resolverFor( + value: AgentSessionRecord | null, + resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, + resolveRollout: () => Promise = async () => null +) { + return createCodexStructuredLaunchResolver({ + store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + resolveWorkspacePath, + resolveCommand: () => '/usr/local/bin/codex', + resolveRollout + }) +} + +describe('codex structured launch resolution', () => { + it('launches the app server in the workspace and account home the record pinned', async () => { + const launch = await resolverFor(record())({ identity: IDENTITY }) + + expect(launch).toEqual({ + command: '/usr/local/bin/codex', + args: ['app-server'], + cwd: '/repos/workspace-1', + codexHome: '/home/work/.codex', + resumeThreadId: null + }) + }) + + it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => { + const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd` + + await withPlatform('win32', async () => { + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, + resolveCommand: () => command + }) + + await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ + command, + args: ['app-server'] + }) + }) + }) + + it('resumes the last thread this session actually proved, not one a caller names', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-old' } }, + { handle: { provider: 'codex', threadId: 'thread-current' } } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: IDENTITY }) + + expect(launch.resumeThreadId).toBe('thread-current') + }) + + it('places the durable user configuration before the app-server subcommand', async () => { + const launch = await resolverFor( + record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] }) + )({ identity: IDENTITY }) + + expect(launch.args).toEqual([ + '--profile', + 'review', + '-c', + 'model_reasoning_effort=high', + 'app-server' + ]) + }) + + it('pins resume to the rollout file that proved the durable thread', async () => { + const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-current' } } + ] as AgentSessionRecord['providerHandleChain'] + }), + async (id) => `/repos/${id}`, + resolveRollout + )({ identity: IDENTITY }) + + expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current') + expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl') + }) + + it('refuses a session pinned to another host rather than starting a second writer here', async () => { + await expect( + resolverFor( + record({ + location: { ...record().location, executionHostId: 'ssh:build-box' } + } as Partial) + )({ identity: IDENTITY }) + ).rejects.toThrow(/local host/) + }) + + it('refuses a WSL session, which is a separate filesystem and process namespace', async () => { + await expect( + resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + }) + + it('refuses a record this adapter does not speak for', async () => { + await expect( + resolverFor(record({ provider: 'claude' } as Partial))({ + identity: IDENTITY + }) + ).rejects.toThrow(/is a claude session/) + await expect( + resolverFor( + record({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/.claude' } }) + )({ + identity: IDENTITY + }) + ).rejects.toThrow(/CODEX_HOME/) + }) + + it('refuses to launch for a session the store has no record of', async () => { + await expect(resolverFor(null)({ identity: IDENTITY })).rejects.toThrow(/no durable/) + }) + + it('surfaces a workspace that no longer resolves instead of falling back to a default cwd', async () => { + await expect( + resolverFor(record(), async () => { + throw new Error('workspace-1 is gone') + })({ identity: IDENTITY }) + ).rejects.toThrow('workspace-1 is gone') + }) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts new file mode 100644 index 00000000000..b1cc7854808 --- /dev/null +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -0,0 +1,81 @@ +// How a durable session record becomes a Codex process launch. +// +// Every input is read back from the record the store already made durable, not +// from the call that triggered the acquire. A client that attaches twice must +// land in the same working directory under the same account home, and a resume +// must name the thread this session actually proved — never one a caller asks +// for, which is how a resume becomes a fork wearing a resume's name. + +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveCodexCommand } from '../codex-cli/command' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { CodexStructuredLaunch } from './codex-structured-session-adapter' +import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' + +export type CodexStructuredLaunchResolverDeps = { + store: AgentSessionRecordStore + /** Absolute path of a workspace on this host. Rejects when the workspace no + * longer resolves, which is the case a stale mobile client hits. */ + resolveWorkspacePath: (workspaceId: string) => Promise + /** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */ + resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + /** Fresh shell/configured environment for this spawn; never written to the session record. */ + resolveEnvironment?: () => Promise + resolveRollout?: typeof resolvePinnedCodexRolloutProof +} + +export function createCodexStructuredLaunchResolver( + deps: CodexStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + const { location, accountHome } = record + if (record.provider !== 'codex') { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + // This adapter spawns a child on the machine the runtime itself runs on. + // A session pinned elsewhere belongs to that host's runtime, and quietly + // starting it here would put a second writer on the same thread. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `codex structured sessions run on the local host, not ${location.executionHostId}` + ) + } + if (accountHome.variable !== 'CODEX_HOME') { + throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) + } + const environment = await deps.resolveEnvironment?.() + const pathEnv = environment?.PATH ?? environment?.Path ?? null + const homePath = environment?.HOME ?? environment?.USERPROFILE + const command = (deps.resolveCommand ?? resolveCodexCommand)({ + pathEnv, + ...(homePath ? { homePath } : {}) + }) + const args = [...(record.launchArgs ?? []), 'app-server'] + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + return { + command, + args, + cwd: await deps.resolveWorkspacePath(location.workspaceId), + codexHome: accountHome.path, + ...(environment ? { env: { ...environment } as Record } : {}), + // An empty chain is a session that has never proved a thread, so it + // starts one; anything else resumes the last link this session proved. + resumeThreadId, + ...(resumeThreadId + ? { + resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( + accountHome.path, + resumeThreadId + ) + } + : {}) + } + } +} diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts new file mode 100644 index 00000000000..915d9edaa83 --- /dev/null +++ b/src/main/codex/codex-structured-location-support.ts @@ -0,0 +1,11 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + ) +} diff --git a/src/main/codex/codex-structured-owner-identity.test.ts b/src/main/codex/codex-structured-owner-identity.test.ts new file mode 100644 index 00000000000..c9a7b682218 --- /dev/null +++ b/src/main/codex/codex-structured-owner-identity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { codexProcessIdentity } from './codex-structured-owner-identity' + +const IDENTITY = { + sessionId: 'session-identity', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: 'thread-1' } +} + +describe('codex process identity', () => { + it('records the observed start time alongside the spawn token', async () => { + await expect( + codexProcessIdentity( + { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, + async () => 123 + ) + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 123, + spawnToken: 'spawn-a' + }) + }) + + it('retries a failed start-time read before giving up', async () => { + const readStartTime = vi + .fn<(pid: number) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(456) + await expect( + codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ processStartTimeMs: 456 }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) + + it('refuses an owner whose start time is unreadable rather than record one no probe can verify', async () => { + // A null start time guarantees every later owner probe answers indeterminate, which is + // a durable latch; refusing here is a retryable failure instead. + const readStartTime = vi.fn(async () => null) + await expect( + codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).rejects.toThrow('start time') + expect(readStartTime).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts new file mode 100644 index 00000000000..2259303a4b8 --- /dev/null +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -0,0 +1,64 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about the child Codex just handed back: the process it +// will later re-prove, and the provider handle link the journal binds to. Both +// must describe the thread Codex actually opened, never the one a client asked +// for. + +/** The child echoes its spawn token here so the owner probe can tell a live + * child of THIS reservation from a same-pid stranger. */ +export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +export async function codexProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + pid: number | undefined + }, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error('codex app-server started without a pid') + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`codex app-server start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} + +export function codexProviderHandleLink(input: { + threadId: string + resumed: boolean + origin?: 'adopted' + fence: number + linkId?: string + observedAt: number +}): AgentSessionProviderHandleLink { + return { + linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128), + handle: { provider: 'codex', threadId: input.threadId }, + origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), + mintedAtFence: input.fence, + observedAt: input.observedAt + } +} diff --git a/src/main/codex/codex-structured-prompt-items.test.ts b/src/main/codex/codex-structured-prompt-items.test.ts new file mode 100644 index 00000000000..21e0d1a76b0 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-items.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { + codexApprovalItem, + codexApprovalOptions, + codexPromptIdentity, + codexQuestionItems +} from './codex-structured-prompt-items' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + encodeCodexQuestionOptionId +} from './codex-structured-prompt-replies' + +const THREAD_ID = 'thread-abc' +const CODEX_ITEM_ID = 'item-4' + +describe('codex approval items', () => { + it('offers only the decisions this request named', () => { + expect(codexApprovalOptions({ availableDecisions: ['accept', 'decline'] })).toEqual([ + { id: 'accept', label: 'Allow' }, + { id: 'decline', label: 'Deny' } + ]) + }) + + it('offers the full set when the request names none, so the turn stays answerable', () => { + expect(codexApprovalOptions({}).map((option) => option.id)).toEqual([ + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ]) + }) + + it('drops a decision this build cannot send rather than offering a dead button', () => { + expect( + codexApprovalOptions({ availableDecisions: ['accept', 'teleport'] }).map((o) => o.id) + ).toEqual(['accept']) + }) + + it('titles the prompt by what codex asked for and starts it pending', () => { + const command = codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept'] }, + detail: 'rm -rf build' + }) + + expect(command).toMatchObject({ + kind: 'approval', + title: 'Run a command?', + detail: 'rm -rf build', + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) + expect( + codexApprovalItem({ method: CODEX_FILE_CHANGE_APPROVAL_METHOD, params: {}, detail: null }) + ).toMatchObject({ title: 'Apply file changes?', detail: null }) + expect( + codexApprovalItem({ method: 'item/other/requestApproval', params: {}, detail: null }) + ).toMatchObject({ title: 'Approve this action?' }) + }) + + it("prefers codex's own reason over the command the item announced", () => { + const item = codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { reason: 'writes outside the workspace' }, + detail: 'rm -rf build' + }) + + expect(item.detail).toBe('writes outside the workspace') + }) + + it('prefers the approval request command and describes file-change grants', () => { + expect( + codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { command: ['git', 'status'] }, + detail: 'parent command' + }).detail + ).toBe('git status') + expect( + codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { command: ['pnpm', 'test'], reason: 'same parent reason' }, + detail: 'parent command' + }).detail + ).toBe('pnpm test') + expect( + codexApprovalItem({ + method: CODEX_FILE_CHANGE_APPROVAL_METHOD, + params: { grantRoot: '/outside' }, + detail: null + }).detail + ).toBe('"/outside"') + }) +}) + +describe('codex question items', () => { + const params = { + questions: [ + { + id: 'q1', + question: 'Which branch?', + options: [{ label: 'main' }, { label: 'release/1.0' }] + }, + { id: 'q2', header: 'Proceed?', options: [{ label: 'yes' }] } + ] + } + + it('makes one journal item per question, each with its own resolution', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items.map((item) => item.questionId)).toEqual(['q1', 'q2']) + expect(items.map((item) => item.body.question)).toEqual(['Which branch?', 'Proceed?']) + expect(items[0]?.body.resolution.state).toBe('pending') + }) + + it('keys each question separately so two answers cannot collide on one row', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items.map((item) => item.identity)).toEqual([ + { provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q1' }, + { provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q2' } + ]) + }) + + it('names the question inside every option id, because codex replies by question', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items[0]?.body.options).toEqual([ + { id: encodeCodexQuestionOptionId('q1', 'main'), label: 'main' }, + { id: encodeCodexQuestionOptionId('q1', 'release/1.0'), label: 'release/1.0' } + ]) + expect(items[0]?.body.options[1]?.id).toBe('q1:release%2F1.0') + }) + + it('skips a question with no id or no prompt rather than minting an unanswerable row', () => { + const items = codexQuestionItems({ + threadId: THREAD_ID, + promptKey: CODEX_ITEM_ID, + params: { questions: [{ question: 'no id' }, { id: 'q3' }, { id: 'q4', question: 'ok' }] } + }) + + expect(items.map((item) => item.questionId)).toEqual(['q4']) + }) + + it('returns nothing when the request carries no questions at all', () => { + expect( + codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params: {} }) + ).toEqual([]) + }) + + it('preserves a free-text path for null options and Other', () => { + const [withoutOptions, withOther] = codexQuestionItems({ + threadId: THREAD_ID, + promptKey: CODEX_ITEM_ID, + params: { + questions: [ + { id: 'q1', question: 'Describe it', options: null }, + { + id: 'q2', + question: 'Pick or type', + options: [{ label: 'Known' }, { label: 'Other', isOther: true }] + } + ] + } + }) + + expect(withoutOptions?.body).toMatchObject({ options: [], freeTextQuestionId: 'q1' }) + expect(withOther?.body).toMatchObject({ + options: [{ id: 'q2:Known', label: 'Known' }], + freeTextQuestionId: 'q2' + }) + }) + + it('keys an approval without a question id', () => { + expect(codexPromptIdentity({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID })).toEqual({ + provider: 'orca', + clientMessageId: 'codex-prompt:thread-abc:item-4' + }) + }) +}) diff --git a/src/main/codex/codex-structured-prompt-items.ts b/src/main/codex/codex-structured-prompt-items.ts new file mode 100644 index 00000000000..4fd05763315 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-items.ts @@ -0,0 +1,188 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + CODEX_APPROVAL_DECISIONS, + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + encodeCodexQuestionOptionId, + type CodexApprovalDecision +} from './codex-structured-prompt-replies' + +// Codex prompt requests → durable journal items. +// +// Codex blocks the turn on these, but the answer may arrive minutes later from +// a different device, so the prompt has to exist as a journal item with its own +// resolution state rather than as live callback state. The reply path already +// lives in `codex-structured-prompt-replies.ts`; this is only the render model. + +const APPROVAL_DECISION_LABELS: Record = { + accept: 'Allow', + acceptForSession: 'Allow for this session', + decline: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +function readParams(params: unknown): Record { + return typeof params === 'object' && params !== null ? (params as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** + * Codex offers a per-request decision set, so the options come off the request + * when it names them. Falling back to the full set is deliberate: a build that + * omits the field still accepts all four, and offering nothing would leave the + * turn blocked with no way to answer it. + */ +export function codexApprovalOptions(params: unknown): AgentJournalPromptOption[] { + const available = readParams(params).availableDecisions + const offered = Array.isArray(available) + ? available.filter((decision): decision is CodexApprovalDecision => + (CODEX_APPROVAL_DECISIONS as readonly unknown[]).includes(decision) + ) + : [] + const decisions = offered.length > 0 ? offered : CODEX_APPROVAL_DECISIONS + return decisions.map((decision) => ({ id: decision, label: APPROVAL_DECISION_LABELS[decision] })) +} + +export function codexApprovalItem(input: { + method: string + params: unknown + /** What is being approved, taken from the item Codex already announced — + * the approval request itself does not repeat the command or the patch. */ + detail: string | null +}): AgentJournalApprovalItem { + const params = readParams(input.params) + return { + kind: 'approval', + title: + input.method === CODEX_FILE_CHANGE_APPROVAL_METHOD + ? 'Apply file changes?' + : input.method === CODEX_COMMAND_APPROVAL_METHOD + ? 'Run a command?' + : 'Approve this action?', + detail: approvalDetail(params) ?? input.detail, + options: codexApprovalOptions(input.params), + resolution: { ...PENDING } + } +} + +function approvalDetail(params: Record): string | null { + const command = params.command + if (typeof command === 'string' && command.length > 0) { + return command + } + if (Array.isArray(command) && command.every((part) => typeof part === 'string')) { + return command.join(' ') + } + const reason = readString(params, 'reason') + if (reason) { + return reason + } + const detail = params.grantRoot ?? params.changes + return detail === undefined ? null : JSON.stringify(detail) +} + +export type CodexQuestionItem = { + questionId: string + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +/** + * One journal item per question, not one per request. Codex takes a single + * reply covering every question, but a client answers them one at a time, and + * each answer has to win its own compare-and-set — so each question needs its + * own resolution state. The reply fires when the last one lands. + */ +export function codexQuestionItems(input: { + threadId: string + promptKey: string + params: unknown +}): CodexQuestionItem[] { + const questions = readParams(input.params).questions + if (!Array.isArray(questions)) { + return [] + } + const items: CodexQuestionItem[] = [] + for (const entry of questions) { + const question = readParams(entry) + const questionId = readString(question, 'id') + const prompt = readString(question, 'question') ?? readString(question, 'header') + if (!questionId || !prompt) { + continue + } + items.push({ + questionId, + identity: codexPromptIdentity({ ...input, questionId }), + body: { + kind: 'question', + question: prompt, + options: questionOptions(question, questionId), + ...(questionAllowsFreeText(question) ? { freeTextQuestionId: questionId } : {}), + resolution: { ...PENDING } + } + }) + } + return items +} + +function questionAllowsFreeText(question: Record): boolean { + const options = question.options + return ( + !Array.isArray(options) || + options.length === 0 || + options.some((option) => readParams(option).isOther === true) + ) +} + +function questionOptions( + question: Record, + questionId: string +): AgentJournalPromptOption[] { + const options = question.options + if (!Array.isArray(options)) { + return [] + } + const mapped: AgentJournalPromptOption[] = [] + for (const entry of options) { + const option = readParams(entry) + const label = readString(option, 'label') + if (label !== null && option.isOther !== true) { + // The option id has to name its question: Codex's reply is a map keyed by + // question id, and the client only ever hands back an option id. + mapped.push({ id: encodeCodexQuestionOptionId(questionId, label), label }) + } + } + return mapped +} + +/** Prompts are live-session state Codex does not persist, so they are keyed in + * the Orca namespace rather than by `(threadId, turnId, ordinal)`. */ +/** Keyed by the prompt, not by the tool item it is about: one shell item can + * ask several times, and each ask is its own journal row to answer. */ +export function codexPromptIdentity(input: { + threadId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `codex-prompt:${input.threadId}:${input.promptKey}${suffix}` + } +} diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts new file mode 100644 index 00000000000..75dfb954fca --- /dev/null +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + applyCodexPromptAnswer, + CodexPromptRegistry, + decodeCodexQuestionOptionId, + encodeCodexQuestionOptionId +} from './codex-structured-prompt-replies' + +function userInputRequest(questionIds: string[]): { + id: number + method: string + params: unknown +} { + return { + id: 5, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-item-1', + threadId: 'thread-1', + turnId: 'turn-1', + questions: questionIds.map((id) => ({ id })) + } + } +} + +describe('codex question option ids', () => { + it('round-trips a question id that itself contains the separator', () => { + const optionId = encodeCodexQuestionOptionId('scope:write', 'yes / no') + + expect(decodeCodexQuestionOptionId(optionId)).toEqual({ + questionId: 'scope:write', + answer: 'yes / no' + }) + }) + + it('reads nothing from an id with no separator', () => { + expect(decodeCodexQuestionOptionId('accept')).toBeNull() + }) +}) + +describe('CodexPromptRegistry', () => { + it('ignores a request that names no item or thread', () => { + const registry = new CodexPromptRegistry() + + expect( + registry.register({ id: 1, method: 'item/tool/requestUserInput', params: { itemId: 'i1' } }) + ).toBeNull() + expect(registry.register({ id: 2, method: 'account/refresh', params: {} })).toBeNull() + }) + + it('keeps two prompts that share one tool item apart', () => { + const registry = new CodexPromptRegistry() + const ask = (id: number, approvalId: string): void => { + registry.register({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', approvalId, threadId: 'thread-1' } + }) + } + + ask(1, 'approval-a') + ask(2, 'approval-b') + + // The second ask must not have replaced the first, or the turn blocks on a + // request nobody can address any more. + expect(registry.find('approval-a')?.requestId).toBe(1) + expect(registry.find('approval-b')?.requestId).toBe(2) + // Nothing addresses the shared item id, because it names two live prompts. + expect(registry.find('codex-item-1')).toBeNull() + }) + + it('addresses a prompt by its journal item id once bound, and forgets both', () => { + const registry = new CodexPromptRegistry() + const prompt = registry.register(userInputRequest(['q1'])) + registry.bindJournalItemId('codex:thread-1:turn-1:2', 'thread-1', 'codex-item-1') + + expect(registry.find('codex:thread-1:turn-1:2')).toBe(prompt) + expect(registry.find('codex-item-1')).toBe(prompt) + + registry.forget(prompt as NonNullable) + expect(registry.find('codex:thread-1:turn-1:2')).toBeNull() + expect(registry.find('codex-item-1')).toBeNull() + }) + + it('keeps identical item ids on different threads independently answerable', () => { + const registry = new CodexPromptRegistry() + const register = (id: number, threadId: string) => + registry.register({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'item-2', threadId } + }) + + register(1, 'thread-root') + register(2, 'thread-child') + registry.bindJournalItemId('journal-root', 'thread-root', 'item-2') + registry.bindJournalItemId('journal-child', 'thread-child', 'item-2') + + expect(registry.find('journal-root')?.requestId).toBe(1) + expect(registry.find('journal-child')?.requestId).toBe(2) + expect(registry.find('item-2')).toBeNull() + }) +}) + +describe('applyCodexPromptAnswer', () => { + it('accepts a bare answer only when the request has one question', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + + expect(applyCodexPromptAnswer(single as NonNullable, 'sure')).toEqual({ + answers: { q1: { answers: ['sure'] } } + }) + }) + + it('refuses an answer that names no question of a multi-question request', () => { + const registry = new CodexPromptRegistry() + const many = registry.register(userInputRequest(['q1', 'q2'])) + + expect(() => applyCodexPromptAnswer(many as NonNullable, 'sure')).toThrow( + 'does not name a question' + ) + expect(() => + applyCodexPromptAnswer( + many as NonNullable, + encodeCodexQuestionOptionId('q3', 'sure') + ) + ).toThrow('does not name a question') + }) + + it('keeps the last answer when a question is answered twice', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + const prompt = single as NonNullable + + applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'first')) + + expect(applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'second'))).toEqual({ + answers: { q1: { answers: ['second'] } } + }) + }) +}) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts new file mode 100644 index 00000000000..ad31d86043a --- /dev/null +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -0,0 +1,209 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' + +// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to +// Orca, and the turn blocks until each one is answered. The journal answers them +// much later, through a durable item id, so this module holds the live request +// ids and turns a chosen option back into the reply payload Codex expects. + +export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' +export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' +export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' + +/** The decisions Codex accepts for both approval requests. Anything else is a + * client-supplied option id that never came from a Codex prompt. */ +export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const +export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number] + +export type CodexPendingPrompt = { + requestId: number | string + method: string + threadId: string + turnId: string | null + codexItemId: string + /** What addresses this prompt. One tool item can ask more than once — a shell + * bridge re-asks per command under the same `itemId` — so the request's own + * `approvalId` is the identity whenever Codex sends one. */ + promptKey: string + /** One entry per question for a user-input request; empty for an approval. */ + questionIds: readonly string[] + answers: Map +} + +/** A user-input request can carry several questions but takes ONE reply, so an + * option id has to name the question it answers. */ +export function encodeCodexQuestionOptionId(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +export function decodeCodexQuestionOptionId( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +function readString(params: unknown, key: string): string | null { + if (typeof params !== 'object' || params === null) { + return null + } + const value = (params as Record)[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readQuestionIds(params: unknown): string[] { + const questions = (params as { questions?: unknown } | null)?.questions + if (!Array.isArray(questions)) { + return [] + } + return questions + .map((question) => (question as { id?: unknown })?.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) +} + +export function isCodexPromptMethod(method: string): boolean { + return ( + method === CODEX_COMMAND_APPROVAL_METHOD || + method === CODEX_FILE_CHANGE_APPROVAL_METHOD || + method === CODEX_USER_INPUT_METHOD + ) +} + +/** + * Live Codex prompt requests for one session, addressable by the journal item + * id the client will eventually answer with. The binding is registered by the + * translation module, because only it knows which journal item a Codex item + * became. + */ +export class CodexPromptRegistry { + private readonly byAddress = new Map() + /** Journal item id to thread-scoped prompt address. */ + private readonly journalItemIds = new Map() + + private address(threadId: string, promptKey: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` + } + + /** Returns null for a request this build does not model, so the caller can + * refuse it instead of leaving Codex blocked on an answer forever. */ + register(request: { + id: number | string + method: string + params: unknown + }): CodexPendingPrompt | null { + const codexItemId = readString(request.params, 'itemId') + const threadId = readString(request.params, 'threadId') + if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { + return null + } + const prompt: CodexPendingPrompt = { + requestId: request.id, + method: request.method, + threadId, + turnId: readString(request.params, 'turnId'), + codexItemId, + promptKey: readString(request.params, 'approvalId') ?? codexItemId, + questionIds: + request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [], + answers: new Map() + } + this.byAddress.set(this.address(prompt.threadId, prompt.promptKey), prompt) + return prompt + } + + /** Called by the translation module once the prompt has a journal id. */ + bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void { + this.journalItemIds.set(journalItemId, this.address(threadId, promptKey)) + } + + /** Falls back to treating the id as a prompt key, which is what it is before + * any binding exists. */ + find(journalItemId: string): CodexPendingPrompt | null { + const address = this.journalItemIds.get(journalItemId) + if (address) { + return this.byAddress.get(address) ?? null + } + const matches = [...this.byAddress.values()].filter( + (prompt) => prompt.promptKey === journalItemId + ) + return matches.length === 1 ? matches[0]! : null + } + + forget(prompt: CodexPendingPrompt): void { + const address = this.address(prompt.threadId, prompt.promptKey) + this.byAddress.delete(address) + for (const [journalItemId, boundAddress] of this.journalItemIds) { + if (boundAddress === address) { + this.journalItemIds.delete(journalItemId) + } + } + } + + clear(): void { + this.byAddress.clear() + this.journalItemIds.clear() + } +} + +/** + * Records one answer and returns the reply payload once the request is fully + * answered. A multi-question user-input request stays pending until every + * question has an answer, because Codex takes one reply for all of them. + */ +export function applyCodexPromptAnswer( + prompt: CodexPendingPrompt, + optionId: string +): Record | null { + if (prompt.method !== CODEX_USER_INPUT_METHOD) { + if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + throw new Error(`${optionId} is not a Codex approval decision`) + } + return { decision: optionId } + } + const decoded = decodeCodexQuestionOptionId(optionId) + const questionId = + decoded?.questionId ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + const answer = decoded?.answer ?? optionId + if (!questionId || !prompt.questionIds.includes(questionId)) { + throw new Error(`${optionId} does not name a question on Codex item ${prompt.codexItemId}`) + } + prompt.answers.set(questionId, answer) + if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { + return null + } + const answers: Record = {} + for (const id of prompt.questionIds) { + answers[id] = { answers: [prompt.answers.get(id) as string] } + } + return { answers } +} + +/** Throws for a prompt Codex is no longer waiting on, which the wire reports as + * "recorded but not confirmed" rather than as a delivered answer. */ +export function answerCodexPrompt( + registry: CodexPromptRegistry, + connection: Pick, + itemId: string, + optionId: string +): void { + const prompt = registry.find(itemId) + if (!prompt) { + throw new Error(`codex app-server is no longer waiting on ${itemId}`) + } + const reply = applyCodexPromptAnswer(prompt, optionId) + if (reply === null) { + return + } + // Forget first: a second answer must find nothing rather than reply twice. + registry.forget(prompt) + connection.respond(prompt.requestId, reply) +} diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts new file mode 100644 index 00000000000..4dbdd0a28f1 --- /dev/null +++ b/src/main/codex/codex-structured-provider-events.ts @@ -0,0 +1,77 @@ +import type { CodexAppServerServerRequest } from './codex-app-server-connection' +import { disposeCodexServerRequest } from './codex-server-request-disposition' +import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' + +type EmitCodexEvent = (session: CodexSession, event: CodexStructuredSessionEvent) => void + +export function deliverCodexNotification( + sessionId: string, + session: CodexSession | undefined, + method: string, + params: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const threadId = readCodexThreadId(params) ?? session.threadId + if (method === 'turn/started' && threadId === session.threadId) { + const turnId = readCodexTurnId(params) + const waiter = turnId ? session.turnIdWaiters.shift() : undefined + waiter?.(turnId as string) + } + emit(session, { type: 'notification', sessionId, threadId, method, params }) +} + +export function deliverCodexServerRequest( + sessionId: string, + session: CodexSession | undefined, + request: CodexAppServerServerRequest, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const disposition = disposeCodexServerRequest(session.prompts, session.connection, request) + const threadId = readCodexThreadId(request.params) ?? session.threadId + if (disposition.kind === 'responded') { + emit(session, { + type: 'server-request', + sessionId, + threadId, + method: request.method, + params: request.params + }) + return + } + const prompt = disposition.prompt + emit(session, { + type: 'prompt', + sessionId, + threadId: prompt.threadId, + method: request.method, + params: request.params, + codexItemId: prompt.codexItemId, + promptKey: prompt.promptKey + }) +} + +export function deliverCodexUnhandledFrame( + sessionId: string, + session: CodexSession | undefined, + kind: string, + payload: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + emit(session, { + type: 'provider-frame', + sessionId, + threadId: readCodexThreadId(payload) ?? session.threadId, + kind, + payload + }) +} diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts new file mode 100644 index 00000000000..e686231e043 --- /dev/null +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -0,0 +1,883 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerLaunch, + openCodexAppServerConnection +} from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' +import { + CodexStructuredSessionAdapter, + type CodexStructuredLaunch, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-adapter' + +const THREAD_ID = 'thread-abc' + +function identityFor(sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +type Route = (params: Record | undefined) => unknown + +// `closed` is readonly on the real connection; the fake flips it so a test can +// kill the child at a chosen moment. +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number; message?: string }[] + closeCount: number +} + +/** Stands in for a live `codex app-server`: every RPC is answered from `routes`, + * and the test drives Codex's own traffic through `handlers`. */ +function fakeCodex(routes: Record = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + replies: [], + closeCount: 0, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + const route = routes[method] + return route ? route(params) : {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code, message) => connection.replies.push({ id, code, message }), + close: async () => { + connection.closeCount += 1 + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + routes['thread/start'] ??= () => ({ + thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, + model: 'gpt-live', + reasoningEffort: 'medium' + }) + routes['thread/resume'] ??= (params) => ({ + thread: { id: (params as { threadId: string }).threadId }, + model: 'gpt-live', + reasoningEffort: 'medium' + }) + return { connections, openConnection, routes } +} + +function adapterFor( + codex: ReturnType, + launch: Partial = {}, + events: CodexStructuredSessionEvent[] = [], + processControl: Partial< + Pick + > = {} +): CodexStructuredSessionAdapter { + return new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), + terminateTurnProcesses: async () => true, + now: () => 1_700_000_000_500, + ...processControl + }) +} + +async function acquired( + codex: ReturnType, + launch: Partial = {}, + events: CodexStructuredSessionEvent[] = [] +): Promise { + const adapter = adapterFor(codex, launch, events) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +describe('CodexStructuredSessionAdapter.acquire', () => { + it('starts a new thread and reports the process and link the lease will prove', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { codexHome: '/codex/home' }) + + const acquisition = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(codex.connections[0].launch.env).toEqual({ + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', + CODEX_HOME: '/codex/home' + }) + expect(codex.connections[0].launch.cwd).toBe('/work/repo') + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/start', + params: { cwd: '/work/repo' } + }) + expect(acquisition.process).toEqual({ + hostId: 'host-1', + pid: 4321, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-9' + }) + expect(acquisition.link).toEqual({ + linkId: `codex-7-${THREAD_ID}`, + handle: { provider: 'codex', threadId: THREAD_ID }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1_700_000_000_500 + }) + }) + + it('resumes the thread the durable handle chain names, not the client one', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { + resumeThreadId: 'thread-proven', + resumePath: '/rollouts/thread-proven.jsonl' + }) + + const acquisition = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 9, + spawnToken: 'spawn-9' + }) + + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/resume', + params: { + threadId: 'thread-proven', + cwd: '/work/repo', + path: '/rollouts/thread-proven.jsonl' + } + }) + expect(acquisition.link.origin).toBe('resumed') + expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) + }) + + it('refuses a resume that lands on a different thread and reaps the child', async () => { + const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) + const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) + ).rejects.toThrow('resumed thread-other instead of thread-proven') + expect(codex.connections[0].closeCount).toBe(1) + }) + + it('refuses a thread Codex never named', async () => { + const codex = fakeCodex({ 'thread/start': () => ({}) }) + const adapter = adapterFor(codex) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) + ).rejects.toThrow('did not name the thread') + expect(codex.connections[0].closeCount).toBe(1) + }) + + it('closes the previous child before re-acquiring at a new fence', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + + expect(codex.connections).toHaveLength(2) + expect(codex.connections[0].closeCount).toBe(1) + expect(codex.connections[1].closeCount).toBe(0) + }) + + it('keeps the traffic Codex sends before the session is published', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + codex.routes['thread/start'] = () => { + // Codex talks as soon as the child is up, which is before the adapter has + // a thread id to publish the session under. + codex.connections[0].handlers.onNotification?.('item/started', { threadId: THREAD_ID }) + codex.connections[0].handlers.onServerRequest?.({ + id: 5, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-early', threadId: THREAD_ID, turnId: 'turn-1' } + }) + return { thread: { id: THREAD_ID } } + } + + const adapter = await acquired(codex, {}, events) + + expect(events.map((event) => event.type)).toEqual(['notification', 'prompt']) + // The early approval is answerable, so Codex is not left blocked on a + // request that arrived a moment too soon. + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-early', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) + }) + + it('refuses to publish a session whose child died while it was being acquired', async () => { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + // The child dies while the acquisition is still reading its identity. + readProcessStartTime: async () => { + codex.connections[0].closed = true + return 1_700_000_000_000 + } + }) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('exited while being acquired') + expect(codex.connections[0].closeCount).toBe(1) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('no live codex app-server') + }) + + it('classifies launch validation failure as pre-spawn without opening a child', async () => { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => { + throw new Error('workspace no longer exists') + }, + openConnection: codex.openConnection + }) + + const error = await adapter + .acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toMatchObject({ + name: 'AgentSessionPreSpawnError', + message: 'workspace no longer exists' + }) + expect(codex.connections).toHaveLength(0) + }) + + it('reports the rollout path Codex named, and null when it named none', async () => { + const withPath = fakeCodex() + const adapter = await acquired(withPath) + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + + const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) }) + const bare = await acquired(withoutPath) + expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull() + }) + + it('lets closeAll cancel and reap an acquisition still opening', async () => { + const codex = fakeCodex() + let releaseOpen = (): void => {} + let markOpenEntered = (): void => {} + const gate = new Promise((resolve) => { + releaseOpen = resolve + }) + const openEntered = new Promise((resolve) => { + markOpenEntered = resolve + }) + const openConnection: typeof openCodexAppServerConnection = async (...args) => { + markOpenEntered() + await gate + return codex.openConnection(...args) + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + await openEntered + + const closing = adapter.closeAll() + releaseOpen() + + await expect(acquiring).rejects.toThrow('superseded while being acquired') + await closing + expect(codex.connections[0]?.closeCount).toBe(1) + }) + + it('fences an acquisition while launch resolution is still pending', async () => { + const launch = Promise.withResolvers() + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: () => launch.promise, + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + + const closing = adapter.closeAll() + launch.resolve({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }) + + await expect(acquiring).rejects.toThrow('superseded while being acquired') + await closing + expect(codex.connections).toHaveLength(0) + }) +}) + +describe('CodexStructuredSessionAdapter.dispatch', () => { + it('accepts a turn Codex names in its response', async () => { + const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const adapter = await acquired(codex) + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'ship it' }, + { type: 'image-ref', path: '/tmp/shot.png' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ] + }, + fence: 7 + }) + + expect(outcome).toEqual({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } + }) + expect(codex.connections[0].calls[1].params).toEqual({ + threadId: THREAD_ID, + clientUserMessageId: 'client-1', + input: [ + { type: 'text', text: 'ship it' }, + { type: 'localImage', path: '/tmp/shot.png' }, + { type: 'image', url: 'https://example.test/a.png' } + ] + }) + }) + + it('accepts a turn named only by the notification that raced the ack', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + codex.routes['turn/start'] = () => { + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-late' } + }) + return {} + } + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(outcome).toMatchObject({ state: 'accepted' }) + expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) + expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) + }) + + it('does not let a child thread answer for the root thread', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + codex.routes['turn/start'] = () => { + // A subagent runs its own thread over the same connection, and its turn + // starts first. + const notify = codex.connections[0].handlers.onNotification + notify?.('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child' } }) + notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-root' } }) + return {} + } + const adapter = await acquired(codex, {}, events) + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(outcome).toEqual({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } + }) + // Each event carries the thread it actually came from, so the journal can + // keep a subagent's turn out of the root conversation. + expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ + 'thread-child', + THREAD_ID + ]) + }) + + it('settles unknown rather than failed when Codex never names the turn', async () => { + vi.useFakeTimers() + try { + const codex = fakeCodex() + const adapter = await acquired(codex) + + const dispatching = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await vi.advanceTimersByTimeAsync(10_000) + + expect(await dispatching).toEqual({ + state: 'unknown', + reason: 'codex app-server started a turn it did not name in time' + }) + } finally { + vi.useRealTimers() + } + }) + + it('rejects only when Codex answered and declined', async () => { + const codex = fakeCodex({ + 'turn/start': () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'turn already running') + } + }) + const adapter = await acquired(codex) + + expect( + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).toEqual({ state: 'rejected', reason: 'turn already running' }) + }) + + it('rethrows a dead child so the wire settles the submission unknown', async () => { + const codex = fakeCodex({ + 'turn/start': () => { + throw new Error('codex app-server connection ended') + } + }) + const adapter = await acquired(codex) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('connection ended') + }) + + it('applies an option change to the next turn only', async () => { + const codex = fakeCodex({ + 'model/list': () => ({ + data: [ + { + model: 'gpt-live', + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + }, + { + model: 'gpt-5', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + } + ], + nextCursor: null + }), + 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) + const adapter = await acquired(codex) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'sandboxEscape', value: 'yes', fence: 7 }) + ).rejects.toThrow('no thread option named sandboxEscape') + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + const turnStart = codex.connections[0].calls.findLast((call) => call.method === 'turn/start') + expect(turnStart?.params).toMatchObject({ model: 'gpt-5', effort: 'high' }) + expect(turnStart?.params).not.toHaveProperty('sandboxEscape') + }) +}) + +describe('CodexStructuredSessionAdapter prompts', () => { + function askApproval(codex: ReturnType): void { + codex.connections[0].handlers.onServerRequest?.({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + } + + it('surfaces an approval request and answers it exactly once', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + + askApproval(codex) + adapter.bindPromptItemId('session-1', 'codex:thread-abc:turn-1:3', 'codex-item-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex:thread-abc:turn-1:3', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + + expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) + expect(codex.connections[0].replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex:thread-abc:turn-1:3', + kind: 'approval', + optionId: 'decline', + fence: 7 + }) + ).rejects.toThrow('no longer waiting on') + expect(codex.connections[0].replies).toHaveLength(1) + }) + + it('answers each approval a tool item asks for separately', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + // A shell bridge re-asks per command under one parent tool item, so only the + // approval id tells the two requests apart. + const ask = (id: number, approvalId: string): void => { + codex.connections[0].handlers.onServerRequest?.({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', approvalId, threadId: THREAD_ID, turnId: 'turn-1' } + }) + } + + ask(11, 'approval-a') + ask(12, 'approval-b') + adapter.bindPromptItemId('session-1', 'journal-a', 'approval-a') + adapter.bindPromptItemId('session-1', 'journal-b', 'approval-b') + for (const [itemId, optionId] of [ + ['journal-b', 'decline'], + ['journal-a', 'accept'] + ]) { + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId, + kind: 'approval', + optionId, + fence: 7 + }) + } + + expect(codex.connections[0].replies).toEqual([ + { id: 12, result: { decision: 'decline' } }, + { id: 11, result: { decision: 'accept' } } + ]) + expect(events.map((event) => (event.type === 'prompt' ? event.promptKey : null))).toEqual([ + 'approval-a', + 'approval-b' + ]) + }) + + it('rejects an option id that is not a Codex decision', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + askApproval(codex) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-1', + kind: 'approval', + optionId: 'yolo', + fence: 7 + }) + ).rejects.toThrow('is not a Codex approval decision') + expect(codex.connections[0].replies).toEqual([]) + }) + + it('holds a multi-question request until every question is answered', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + codex.connections[0].handlers.onServerRequest?.({ + id: 12, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-item-2', + threadId: THREAD_ID, + turnId: 'turn-1', + questions: [{ id: 'q1' }, { id: 'q2' }] + } + }) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-2', + kind: 'question', + optionId: encodeCodexQuestionOptionId('q1', 'yes'), + fence: 7 + }) + expect(codex.connections[0].replies).toEqual([]) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-2', + kind: 'question', + optionId: encodeCodexQuestionOptionId('q2', 'no'), + fence: 7 + }) + + expect(codex.connections[0].replies).toEqual([ + { id: 12, result: { answers: { q1: { answers: ['yes'] }, q2: { answers: ['no'] } } } } + ]) + }) + + it('declines MCP elicitation and journals the explicit disposition', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + await acquired(codex, {}, events) + + codex.connections[0].handlers.onServerRequest?.({ + id: 13, + method: 'mcpServer/elicitation/request', + params: { itemId: 'codex-item-3', threadId: THREAD_ID } + }) + + expect(codex.connections[0].replies).toEqual([ + { id: 13, result: { action: 'decline', content: null, _meta: null } } + ]) + expect(events.some((event) => event.type === 'prompt')).toBe(false) + }) + + it('surfaces an answer to a prompt Codex already forgot', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-gone', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + ).rejects.toThrow('no longer waiting on codex-item-gone') + }) +}) + +describe('CodexStructuredSessionAdapter lifecycle', () => { + it('keeps sessions isolated and closes each child once', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex) + await adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-a' }) + await adapter.acquire({ identity: identityFor('session-2'), fence: 1, spawnToken: 'spawn-b' }) + + codex.connections[0].handlers.onServerRequest?.({ + id: 21, + method: 'item/fileChange/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-2', + itemId: 'codex-item-1', + kind: 'approval', + optionId: 'accept', + fence: 1 + }) + ).rejects.toThrow('no longer waiting on') + + await adapter.closeAll() + expect(codex.connections.map((connection) => connection.closeCount)).toEqual([1, 1]) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 1 }) + ).rejects.toThrow('no live codex app-server for session session-1') + }) + + it('retains ownership until a child exit is proven and reports it once', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + + const connection = codex.connections[0] + connection.close = async () => { + connection.closeCount += 1 + return false + } + connection.handlers.onExit?.(new Error('codex app-server connection ended')) + + expect(events.at(-1)).toEqual({ + type: 'ended', + sessionId: 'session-1', + reason: 'codex app-server connection ended' + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('no live codex app-server') + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + await expect(adapter.closeSession('session-1')).resolves.toBe(false) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + }) + + it('keeps the live session when a child it already replaced dies', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length + + codex.connections[0].handlers.onExit?.(new Error('the superseded child died')) + + expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit) + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + }) + + it('ignores Codex traffic that arrives after the session is gone', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + const connection = codex.connections[0] + + await adapter.closeSession('session-1') + connection.handlers.onNotification?.('item/agentMessage/delta', { delta: 'x' }) + connection.handlers.onServerRequest?.({ + id: 31, + method: 'item/fileChange/requestApproval', + params: { itemId: 'codex-item-9', threadId: THREAD_ID } + }) + + expect(connection.replies).toEqual([]) + }) + + it('flushes the final coalesced text before a graceful close', async () => { + const codex = fakeCodex() + const bodies: AgentJournalMessageItem[] = [] + const tombstones: unknown[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body) => { + if (body.kind === 'message') { + bodies.push(body) + } + }, + appendTombstone: (identity) => tombstones.push(identity), + publish: () => {} + } + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: sink + }) + const notify = codex.connections[0]!.handlers.onNotification + notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1' } }) + notify?.('item/started', { + threadId: THREAD_ID, + item: { type: 'agentMessage', id: 'item-1', text: '' } + }) + notify?.('item/agentMessage/delta', { + threadId: THREAD_ID, + itemId: 'item-1', + delta: 'last words' + }) + + await adapter.closeSession('session-1') + + expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }]) + expect(tombstones).toContainEqual({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + }) + }) +}) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts new file mode 100644 index 00000000000..ed209e4c5c9 --- /dev/null +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -0,0 +1,325 @@ +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeFailedCodexAcquisition, + stopSupersededCodexAcquisition +} from './codex-structured-acquisition-lifecycle' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' +import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { openCodexThread } from './codex-structured-thread-open' +import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { supportsCodexStructuredLocation } from './codex-structured-location-support' +import { + closeAllCodexSessions, + closeCodexPublishedSession, + closeCodexSession, + handleCodexSessionExit +} from './codex-structured-session-close' +import { + applyCodexStructuredSessionOption, + readLiveCodexSessionOptions, + reportedCodexThreadOptions, + restoredCodexSessionOptions +} from './codex-structured-session-options' +import { + CodexAcquisitionRegistry, + type CodexAcquisitionAttempt, + type CodexSession, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-state' +import { + deliverCodexNotification, + deliverCodexServerRequest, + deliverCodexUnhandledFrame +} from './codex-structured-provider-events' +import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' + +export type { + CodexStructuredLaunch, + CodexStructuredSessionAdapterDeps, + CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquisitions = new CodexAcquisitionRegistry() + private readonly turnCancellation: CodexStructuredTurnCancellation + + constructor(private readonly deps: CodexStructuredSessionAdapterDeps) { + this.turnCancellation = new CodexStructuredTurnCancellation({ + captureTurnProcesses: deps.captureTurnProcesses, + terminateTurnProcesses: deps.terminateTurnProcesses, + requestTimeoutMs: deps.requestTimeoutMs, + emit: (session, event) => this.emit(session, event) + }) + } + + supportsLocation = supportsCodexStructuredLocation + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const { previousAttempt, attempt } = this.acquisitions.start(sessionId) + const acquisition = attempt.window + let primaryThreadId = + input.identity.providerHandle.kind === 'codex' ? input.identity.providerHandle.threadId : null + const translator = input.events + ? createCodexJournalTranslator({ + sink: input.events, + primaryThreadId: () => primaryThreadId, + bindPromptItemId: (journalItemId, threadId, promptKey) => + acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey) + }) + : null + const open = this.deps.openConnection ?? openCodexAppServerConnection + + try { + await stopSupersededCodexAcquisition({ + sessionId, + registry: this.acquisitions, + replacement: attempt, + previous: previousAttempt + }) + this.acquisitions.assertCurrent(sessionId, attempt) + if (!(await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent))) { + throw new Error(`codex app-server for session ${sessionId} could not be stopped`) + } + this.acquisitions.assertCurrent(sessionId, attempt) + const launch = await this.deps + .resolveLaunch({ identity: input.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + this.acquisitions.assertCurrent(sessionId, attempt) + const connection = await open( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: buildCodexStructuredChildEnvironment(launch, input.spawnToken) + }, + { + onNotification: (method, params) => + this.deliver(acquisition, sessionId, () => + this.handleNotification(sessionId, method, params) + ), + onServerRequest: (request) => + this.deliver(acquisition, sessionId, () => + this.handleServerRequest(sessionId, request) + ), + onUnhandledFrame: (kind, payload) => + this.deliver(acquisition, sessionId, () => + this.handleUnhandledFrame(sessionId, kind, payload) + ), + onExit: (error) => { + acquisition.prompts.clear() + handleCodexSessionExit({ + sessions: this.sessions, + sessionId, + connection: acquisition.connection, + error, + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + } + } + ) + acquisition.connection = connection + this.acquisitions.assertCurrent(sessionId, attempt) + const opened = await openCodexThread(connection, launch, this.deps.requestTimeoutMs) + this.acquisitions.assertCurrent(sessionId, attempt) + primaryThreadId = opened.threadId + translator?.restoreThread(opened.threadId, opened.thread ?? {}) + const process = await codexProcessIdentity( + { ...input, pid: connection.pid }, + this.deps.readProcessStartTime + ) + this.acquisitions.assertCurrent(sessionId, attempt) + const acquired: AgentSessionAcquisition = { + process, + link: codexProviderHandleLink({ + threadId: opened.threadId, + resumed: launch.resumeThreadId !== null, + fence: input.fence, + linkId: this.deps.mintLinkId?.(), + observedAt: this.deps.now?.() ?? Date.now() + }) + } + // Publish only after every promised identity is proven and this attempt still owns the child. + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } + this.acquisitions.assertCurrent(sessionId, attempt) + this.acquisitions.deleteIfCurrent(sessionId, attempt) + const session: CodexSession = { + connection, + ended: false, + threadId: opened.threadId, + historyPath: opened.historyPath, + prompts: acquisition.prompts, + options: restoredCodexSessionOptions(input.options), + reportedOptions: reportedCodexThreadOptions(opened), + turnIdWaiters: [], + translator + } + this.turnCancellation.register(session) + this.sessions.set(sessionId, session) + for (const event of acquisition.drain()) { + event() + } + return acquired + } catch (error) { + // Reap this attempt's child only. A replacement already published for the + // same session keeps running. + if (this.sessions.get(sessionId)?.connection !== acquisition.connection) { + return closeFailedCodexAcquisition({ + sessionId, + registry: this.acquisitions, + attempt, + cause: error, + dispose: () => translator?.dispose() + }) + } + this.acquisitions.deleteIfCurrent(sessionId, attempt) + throw error + } finally { + attempt.finish() + } + } + + /** Buffers pre-publication events and drops events from superseded children. */ + private deliver( + acquisition: CodexAcquisitionAttempt['window'], + sessionId: string, + event: () => void + ): void { + if (acquisition.buffer(event)) { + return + } + if (this.sessions.get(sessionId)?.connection === acquisition.connection) { + event() + } + } + + private handleNotification(sessionId: string, method: string, params: unknown): void { + const session = this.sessions.get(sessionId) + if (session && this.turnCancellation.handleNotification(sessionId, session, method, params)) { + return + } + deliverCodexNotification(sessionId, session, method, params, (session, event) => + this.emit(session, event) + ) + } + + /** Journal first so observers never see an event ahead of its durable row. */ + private emit(session: CodexSession, event: CodexStructuredSessionEvent): void { + session.translator?.handle(event) + this.deps.onEvent?.(event) + } + + private handleServerRequest( + sessionId: string, + request: Parameters[2] + ): void { + deliverCodexServerRequest(sessionId, this.sessions.get(sessionId), request, (session, event) => + this.emit(session, event) + ) + } + + private handleUnhandledFrame(sessionId: string, kind: string, params: unknown): void { + deliverCodexUnhandledFrame( + sessionId, + this.sessions.get(sessionId), + kind, + params, + (session, event) => this.emit(session, event) + ) + } + + bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => + this.sessions + .get(sessionId) + ?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey) + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise { + const session = this.session(input.sessionId) + await this.turnCancellation.captureBaseline(session) + return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + } + + async cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> { + const session = this.session(input.sessionId) + return this.turnCancellation.cancel(session, input.turnId) + } + + async answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise { + const session = this.session(input.sessionId) + answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId) + } + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + if (!isCodexTurnOptionKey(input.key)) { + throw new Error(`codex app-server has no thread option named ${input.key}`) + } + return applyCodexStructuredSessionOption( + this.session(input.sessionId), + input.key, + input.value, + this.deps.requestTimeoutMs + ) + } + + readOptions = (input: { sessionId: string; fence: number }) => + readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + historyFilePath = async (input: { + identity: AgentSessionJournalIdentity + }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null + + closeSession = (sessionId: string): Promise => + closeCodexSession(sessionId, this.sessions, this.acquisitions, this.deps.onEvent) + disposeSession = (sessionId: string): Promise => this.closeSession(sessionId) + closeAll = (): Promise => + closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) => + this.disposeSession(sessionId) + ) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.closeSession(input.sessionId) + + private session(sessionId: string): CodexSession { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + throw new Error(`no live codex app-server for session ${sessionId}`) + } + return session + } +} diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts new file mode 100644 index 00000000000..aed0722b79b --- /dev/null +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type CodexAppServerLaunch, + type openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAppServerUnsupportedError } from './codex-app-server-session' +import { + CodexStructuredSessionAdapter, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-adapter' + +const THREAD_ID = 'thread-abc' +const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +type Route = (params: Record | undefined) => unknown +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +function identity(): AgentSessionJournalIdentity { + return { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +function fakeCodex(): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const routes: Record = { + 'thread/resume': () => ({ thread: { id: THREAD_ID } }) + } + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + return routes[method]?.(params) ?? {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + return { connections, openConnection, routes } +} + +async function acquired( + codex: ReturnType, + events: CodexStructuredSessionEvent[] = [], + processControl: Partial< + Pick + > = {} +): Promise { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: THREAD_ID + }), + onEvent: (event) => events.push(event), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), + terminateTurnProcesses: async () => true, + ...processControl + }) + await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +function completeTurn(codex: ReturnType, turnId = 'turn-1'): void { + codex.connections[0].handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: turnId, status: 'interrupted' } + }) +} + +describe('CodexStructuredSessionAdapter.cancelTurn', () => { + it('confirms an interrupt Codex acknowledged', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0].calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: THREAD_ID, turnId: 'turn-1' } + }) + }) + + it('reports not-cancelled when Codex declines or lacks the method', async () => { + const declined = fakeCodex() + declined.routes['turn/interrupt'] = () => { + throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn') + } + const absent = fakeCodex() + absent.routes['turn/interrupt'] = () => { + throw new CodexAppServerUnsupportedError('no turn/interrupt') + } + + await expect( + (await acquired(declined)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).resolves.toEqual({ cancelled: false }) + await expect( + (await acquired(absent)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).resolves.toEqual({ cancelled: false }) + }) + + it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => { + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + throw new Error('codex app-server turn/interrupt exceeded 30000ms') + } + + await expect( + (await acquired(codex)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).rejects.toThrow('exceeded 30000ms') + }) + + it('publishes terminal state only after streaming interruption is physically settled', async () => { + const events: CodexStructuredSessionEvent[] = [] + let finishTermination!: (terminated: boolean) => void + const termination = new Promise((resolve) => { + finishTermination = resolve + }) + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => termination + }) + codex.connections[0].handlers.onNotification?.('item/agentMessage/delta', { + threadId: THREAD_ID, + turnId: 'turn-1', + itemId: 'item-1', + delta: 'still streaming' + }) + + const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) + expect(events).toContainEqual(expect.objectContaining({ method: 'item/agentMessage/delta' })) + expect(events).not.toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + + finishTermination(true) + await expect(pending).resolves.toEqual({ cancelled: true }) + expect(events.at(-1)).toMatchObject({ method: 'turn/completed' }) + }) + + it('starts physical termination without waiting for the interrupt receipt', async () => { + let finishInterrupt!: () => void + const interruptReceipt = new Promise((resolve) => { + finishInterrupt = resolve + }) + const terminateTurnProcesses = vi.fn(async () => true) + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => interruptReceipt + const adapter = await acquired(codex, [], { terminateTurnProcesses }) + + const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(terminateTurnProcesses).toHaveBeenCalledOnce()) + finishInterrupt() + + await expect(pending).resolves.toEqual({ cancelled: true }) + }) + + it('keeps the turn live when process termination cannot be verified', async () => { + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => false + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + }) + + it('accepts an immediate resend after verified interruption', async () => { + let nextTurn = 0 + const codex = fakeCodex() + codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } }) + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { turnId: 'turn-2' } + }) + }) + + it('does not strand a deferred completion when the interrupt receipt fails', async () => { + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + throw new Error('interrupt receipt lost') + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => true + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).rejects.toThrow('interrupt receipt lost') + expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + }) +}) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts new file mode 100644 index 00000000000..c4a69b19f2d --- /dev/null +++ b/src/main/codex/codex-structured-session-close.ts @@ -0,0 +1,89 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' +import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { + cancelCodexAcquisitionAttempt, + type CodexAcquisitionRegistry, + type CodexSession, + type CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export function handleCodexSessionExit(input: { + sessions: Map + sessionId: string + connection: CodexAppServerConnection | null + error: Error + onEvent?: (event: CodexStructuredSessionEvent) => void +}): void { + const session = input.sessions.get(input.sessionId) + if (!session || session.connection !== input.connection || session.ended) { + return + } + session.ended = true + const event = { type: 'ended', sessionId: input.sessionId, reason: input.error.message } as const + session.translator?.handle(event) + input.onEvent?.(event) + session.translator?.dispose() +} + +export async function closeCodexPublishedSession( + sessions: Map, + sessionId: string, + onEvent?: (event: CodexStructuredSessionEvent) => void +): Promise { + const session = sessions.get(sessionId) + if (!session) { + return true + } + session.prompts.clear() + // Keep the session indexed until the child exit is observed. A timeout or + // failed kill must leave the live connection available for a safe retry. + const exited = await session.connection.close() + if (exited !== true) { + return false + } + sessions.delete(sessionId) + if (!session.ended) { + session.ended = true + const event: CodexStructuredSessionEvent = { + type: 'ended', + sessionId, + reason: 'codex session closed' + } + session.translator?.handle(event) + onEvent?.(event) + session.translator?.flush() + session.translator?.dispose() + } + return true +} + +export async function closeCodexSession( + sessionId: string, + sessions: Map, + acquisitions: CodexAcquisitionRegistry, + onEvent?: (event: CodexStructuredSessionEvent) => void +): Promise { + const attempt = acquisitions.get(sessionId) + if (!(await cancelCodexAcquisitionAttempt(attempt))) { + return false + } + if (attempt) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } + return closeCodexPublishedSession(sessions, sessionId, onEvent) +} + +export async function closeAllCodexSessions( + sessions: Map, + acquisitions: CodexAcquisitionRegistry, + close: (sessionId: string) => Promise +): Promise { + acquisitions.close() + await closeProcessRegistry({ + attempts: 3, + hasEntries: () => sessions.size > 0 || acquisitions.size > 0, + entryIds: () => new Set([...sessions.keys(), ...acquisitions.sessionIds()]), + closeEntry: close, + failureMessage: 'codex structured session shutdown could not prove every child stopped' + }) +} diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts new file mode 100644 index 00000000000..96dd56b11e6 --- /dev/null +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { + applyCodexStructuredSessionOption, + readCodexStructuredSessionOptions, + reportedCodexThreadOptions, + restoredCodexSessionOptions +} from './codex-structured-session-options' +import type { CodexSession } from './codex-structured-session-state' + +function optionSession(request: CodexAppServerConnection['request']): CodexSession { + return { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + ended: false, + threadId: 'thread-1', + historyPath: null, + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(), + reportedOptions: { model: 'gpt-live', effort: 'high' }, + turnIdWaiters: [], + translator: null + } +} + +describe('structured Codex session options', () => { + it('filters restored records to recognized turn options', () => { + expect( + Object.fromEntries( + restoredCodexSessionOptions({ + model: 'gpt-live', + effort: 'high', + threadId: 'thread-injected', + input: 'input-injected' + }) + ) + ).toEqual({ model: 'gpt-live', effort: 'high' }) + }) + + it('hydrates paged provider models and their supported efforts', async () => { + const request = vi.fn(async (_method: string, params?: Record) => + params?.cursor + ? { + data: [ + { + model: 'gpt-second', + displayName: 'GPT Second', + description: 'Fast', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Quick reasoning' } + ], + defaultReasoningEffort: 'low', + isDefault: false + } + ], + nextCursor: null + } + : { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: 'page-2' + } + ) + + await expect( + readCodexStructuredSessionOptions({ + connection: { request } as never, + current: { model: 'gpt-live', effort: 'medium' } + }) + ).resolves.toEqual({ + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium', description: 'Balanced' }, + { value: 'high', label: 'High', description: 'Deep reasoning' } + ] + }, + { + id: 'gpt-second', + label: 'GPT Second', + description: 'Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }] + } + ], + current: { model: 'gpt-live', effort: 'medium' } + }) + expect(request).toHaveBeenNthCalledWith( + 2, + 'model/list', + { limit: 100, includeHidden: false, cursor: 'page-2' }, + { timeoutMs: undefined } + ) + }) + + it('hydrates current values from thread start or resume', () => { + expect( + reportedCodexThreadOptions({ + threadId: 'thread-1', + historyPath: null, + model: 'gpt-live', + effort: 'high' + }) + ).toEqual({ model: 'gpt-live', effort: 'high' }) + }) + + it('reconciles an incompatible effort when only the model changes', async () => { + const session = optionSession( + vi.fn(async () => ({ + data: [ + { + model: 'gpt-live', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + }, + { + model: 'gpt-fast', + supportedReasoningEfforts: [{ reasoningEffort: 'low' }], + defaultReasoningEffort: 'low' + } + ], + nextCursor: null + })) + ) + + await expect( + applyCodexStructuredSessionOption(session, 'model', 'gpt-fast', undefined) + ).resolves.toEqual({ model: 'gpt-fast', effort: 'low' }) + }) + + it('rejects values absent from the provider catalog', async () => { + const session = optionSession( + vi.fn(async () => ({ + data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }], + nextCursor: null + })) + ) + + await expect( + applyCodexStructuredSessionOption(session, 'model', 'not-entitled', undefined) + ).rejects.toThrow('does not offer model not-entitled') + await expect( + applyCodexStructuredSessionOption(session, 'effort', 'high', undefined) + ).rejects.toThrow('does not support high') + }) +}) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts new file mode 100644 index 00000000000..e7ff155625c --- /dev/null +++ b/src/main/codex/codex-structured-session-options.ts @@ -0,0 +1,203 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import type { CodexOpenedThread } from './codex-structured-thread-open' +import type { CodexSession } from './codex-structured-session-state' +import { isCodexTurnOptionKey } from './codex-structured-turn-start' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' + +const MODEL_PAGE_LIMIT = 100 +const MAX_MODEL_PAGES = 20 + +export function restoredCodexSessionOptions( + options: Readonly> | undefined +): Map { + return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function effortLabel(value: string): string { + return value === 'xhigh' + ? 'Extra high' + : value === 'minimal' + ? 'Minimal' + : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function effortChoice(value: unknown): AgentSessionOptionChoice | null { + const row = record(value) + const effort = text(row?.reasoningEffort) + if (!effort) { + return null + } + const description = text(row?.description) + return { + value: effort, + label: effortLabel(effort), + ...(description ? { description } : {}) + } +} + +function modelOption(value: unknown): AgentSessionModelOption | null { + const row = record(value) + if (!row) { + return null + } + const id = text(row.model) ?? text(row.id) + const label = text(row.displayName) ?? id + if (!id || !label || row.hidden === true) { + return null + } + const description = text(row.description) + const defaultEffort = text(row.defaultReasoningEffort) + const efforts = Array.isArray(row.supportedReasoningEfforts) + ? row.supportedReasoningEfforts + .map(effortChoice) + .filter((choice): choice is AgentSessionOptionChoice => choice !== null) + : [] + return { + id, + label, + ...(description ? { description } : {}), + isDefault: row.isDefault === true, + ...(defaultEffort ? { defaultEffort } : {}), + efforts + } +} + +export async function readCodexStructuredSessionOptions(input: { + connection: Pick + current: { model?: string; effort?: string } + timeoutMs?: number +}): Promise { + const models: AgentSessionModelOption[] = [] + let cursor: string | null = null + for (let page = 0; page < MAX_MODEL_PAGES; page += 1) { + const response = record( + await input.connection.request( + 'model/list', + { limit: MODEL_PAGE_LIMIT, includeHidden: false, ...(cursor ? { cursor } : {}) }, + { timeoutMs: input.timeoutMs } + ) + ) + const rows = Array.isArray(response?.data) ? response.data : [] + for (const row of rows) { + const parsed = modelOption(row) + if (parsed && !models.some((model) => model.id === parsed.id)) { + models.push(parsed) + } + } + cursor = text(response?.nextCursor) + if (!cursor) { + break + } + } + if (input.current.model && !models.some((model) => model.id === input.current.model)) { + models.push({ + id: input.current.model, + label: input.current.model, + isDefault: false, + efforts: [] + }) + } + const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id + if (!model) { + throw new Error('codex app-server returned no available models') + } + return { + models, + current: { model, ...(input.current.effort ? { effort: input.current.effort } : {}) } + } +} + +export function reportedCodexThreadOptions( + opened: CodexOpenedThread +): CodexSession['reportedOptions'] { + return { + ...(opened.model ? { model: opened.model } : {}), + ...(opened.effort ? { effort: opened.effort } : {}) + } +} + +export function readLiveCodexSessionOptions( + session: CodexSession, + timeoutMs: number | undefined +): Promise { + const model = session.options.get('model') ?? session.reportedOptions.model + const effort = session.options.get('effort') ?? session.reportedOptions.effort + return readCodexStructuredSessionOptions({ + connection: session.connection, + current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + timeoutMs + }) +} + +export async function applyCodexStructuredSessionOption( + session: CodexSession, + key: string, + value: string, + timeoutMs: number | undefined +): Promise>> { + try { + return await applyValidatedCodexStructuredSessionOption(session, key, value, timeoutMs) + } catch (error) { + throw new AgentSessionOptionRejectedError(error) + } +} + +async function applyValidatedCodexStructuredSessionOption( + session: CodexSession, + key: string, + value: string, + timeoutMs: number | undefined +): Promise>> { + if (key !== 'model' && key !== 'effort') { + session.options.set(key, value) + return Object.fromEntries(session.options) + } + const priorModel = session.options.get('model') ?? session.reportedOptions.model + const priorEffort = session.options.get('effort') ?? session.reportedOptions.effort + const catalog = await readCodexStructuredSessionOptions({ + connection: session.connection, + current: { + ...(priorModel ? { model: priorModel } : {}), + ...(priorEffort ? { effort: priorEffort } : {}) + }, + timeoutMs + }) + if (key === 'model' && !catalog.models.some((entry) => entry.id === value)) { + throw new Error(`codex app-server does not offer model ${value}`) + } + const modelId = key === 'model' ? value : catalog.current.model + const model = catalog.models.find((entry) => entry.id === modelId) + const requestedEffort = key === 'effort' ? value : priorEffort + if ( + key === 'effort' && + (!model?.efforts.length || !model.efforts.some((effort) => effort.value === requestedEffort)) + ) { + throw new Error(`codex app-server model ${modelId} does not support ${value}`) + } + const effort = + model?.efforts.length === 0 + ? undefined + : (model?.efforts.find((entry) => entry.value === requestedEffort)?.value ?? + model?.defaultEffort ?? + model?.efforts[0]?.value) + session.options.set('model', modelId) + if (effort) { + session.options.set('effort', effort) + } else { + session.options.delete('effort') + } + return Object.fromEntries(session.options) +} diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts new file mode 100644 index 00000000000..3883dad4600 --- /dev/null +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { + CodexStructuredSessionAdapter, + type CodexStructuredLaunch +} from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-1' +const LAUNCH: CodexStructuredLaunch = { + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null +} + +function identity(): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +describe('CodexStructuredSessionAdapter shutdown', () => { + it('refuses acquisitions that enter after closeAll starts', async () => { + const connections: CodexAppServerConnection[] = [] + const openConnection = (async () => { + const connection = { + pid: 4321, + closed: false, + request: async (method: string) => + method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } satisfies CodexAppServerConnection + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + const firstLaunch = Promise.withResolvers() + let launchCount = 0 + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: () => { + launchCount += 1 + return launchCount === 1 ? firstLaunch.promise : Promise.resolve(LAUNCH) + }, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const first = adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await vi.waitFor(() => expect(launchCount).toBe(1)) + + const closing = adapter.closeAll() + const second = adapter.acquire({ identity: identity(), fence: 8, spawnToken: 'spawn-2' }) + const acquisitions = Promise.allSettled([first, second]) + firstLaunch.resolve(LAUNCH) + + const [firstResult, secondResult] = await acquisitions + await closing + expect(firstResult).toMatchObject({ status: 'rejected' }) + expect(secondResult).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'codex structured session adapter is closing' }) + }) + expect(connections).toHaveLength(0) + }) + + it('bounds shutdown when a provider child never proves exit', async () => { + const close = vi.fn(async () => false) + const openConnection = (async () => + ({ + pid: 4321, + closed: false, + request: async (method: string) => + method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close + }) satisfies CodexAppServerConnection) as typeof openCodexAppServerConnection + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => LAUNCH, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + + await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await expect(adapter.closeAll()).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + expect(close).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts new file mode 100644 index 00000000000..1eb6d8d8d1c --- /dev/null +++ b/src/main/codex/codex-structured-session-state.ts @@ -0,0 +1,166 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { CodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' + +export type CodexStructuredLaunch = { + command: string + args: string[] + cwd: string + codexHome: string | null + resumeThreadId: string | null + resumePath?: string | null + env?: Record +} + +export type CodexStructuredSessionEvent = + | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown } + | { + type: 'prompt' + sessionId: string + threadId: string + method: string + params: unknown + codexItemId: string + promptKey: string + } + | { type: 'ended'; sessionId: string; reason: string } + +export type CodexStructuredSessionAdapterDeps = { + resolveLaunch: (input: { + identity: AgentSessionJournalIdentity + }) => Promise + onEvent?: (event: CodexStructuredSessionEvent) => void + openConnection?: typeof openCodexAppServerConnection + readProcessStartTime?: (pid: number) => Promise + mintLinkId?: () => string + now?: () => number + requestTimeoutMs?: number + captureTurnProcesses?: (rootPid: number) => Promise + terminateTurnProcesses?: ( + rootPid: number, + baseline: CodexTurnProcessSnapshot | null + ) => Promise +} + +export type CodexSession = { + connection: CodexAppServerConnection + ended: boolean + threadId: string + historyPath: string | null + prompts: CodexAcquisitionWindow['prompts'] + options: Map + reportedOptions: { model?: string; effort?: string } + turnIdWaiters: ((turnId: string) => void)[] + translator: CodexJournalTranslator | null +} + +export type CodexAcquisitionAttempt = { + window: CodexAcquisitionWindow + cancelled: boolean + exitProven: boolean + finished: Promise + finish: () => void +} + +export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt { + let finish = (): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + return { + window: new CodexAcquisitionWindow(), + cancelled: false, + exitProven: false, + finished, + finish + } +} + +export class CodexAcquisitionRegistry { + private readonly attempts = new Map() + private closing = false + + get size(): number { + return this.attempts.size + } + + start(sessionId: string): { + previousAttempt: CodexAcquisitionAttempt | undefined + attempt: CodexAcquisitionAttempt + } { + if (this.closing) { + throw new Error('codex structured session adapter is closing') + } + const previousAttempt = this.attempts.get(sessionId) + const attempt = createCodexAcquisitionAttempt() + this.attempts.set(sessionId, attempt) + return { previousAttempt, attempt } + } + + assertCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void { + if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) { + throw new Error(`codex session ${sessionId} was superseded while being acquired`) + } + } + + get(sessionId: string): CodexAcquisitionAttempt | undefined { + return this.attempts.get(sessionId) + } + + deleteIfCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void { + if (this.attempts.get(sessionId) === attempt) { + this.attempts.delete(sessionId) + } + } + + restoreIfCurrent( + sessionId: string, + replacement: CodexAcquisitionAttempt, + previous: CodexAcquisitionAttempt + ): void { + if (this.attempts.get(sessionId) === replacement) { + this.attempts.set(sessionId, previous) + } + } + + async closeFailedAttempt(sessionId: string, attempt: CodexAcquisitionAttempt): Promise { + const stopped = (await attempt.window.connection?.close()) ?? true + if (stopped) { + attempt.exitProven = true + this.deleteIfCurrent(sessionId, attempt) + } + return stopped + } + + sessionIds(): IterableIterator { + return this.attempts.keys() + } + + close(): void { + this.closing = true + } +} + +export async function cancelCodexAcquisitionAttempt( + attempt: CodexAcquisitionAttempt | undefined +): Promise { + if (!attempt) { + return true + } + return cancelProcessAcquisition({ + cancel: () => { + attempt.cancelled = true + }, + connection: () => attempt.window.connection, + exitProven: () => attempt.exitProven, + finished: attempt.finished + }) +} diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts new file mode 100644 index 00000000000..349246ecf29 --- /dev/null +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -0,0 +1,39 @@ +// The handful of facts Orca reads out of Codex app-server payloads. Codex has +// moved these fields between the envelope and a nested `thread` / `turn` object +// across releases, so each reader accepts both shapes rather than pinning one. + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** `thread/start`, `thread/resume`, and `thread/started` all name the thread. */ +export function readCodexThreadId(payload: unknown): string | null { + const root = record(payload) + if (!root) { + return null + } + return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId) +} + +/** Rollout file for the thread, when Codex reports one. Journal recovery reads + * it; a null just falls back to the existing session-file resolver. */ +export function readCodexThreadPath(payload: unknown): string | null { + const root = record(payload) + return root ? nonEmptyString(record(root.thread)?.path) : null +} + +/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry + * the same under `turn`, and older builds put `turnId` on the envelope. */ +export function readCodexTurnId(payload: unknown): string | null { + const root = record(payload) + if (!root) { + return null + } + return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId) +} diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts new file mode 100644 index 00000000000..fe977d6a37d --- /dev/null +++ b/src/main/codex/codex-structured-thread-open.ts @@ -0,0 +1,61 @@ +// Starting or resuming the single Codex thread a structured session owns. +// +// The reply is verified before the caller registers the session, because a +// resume that lands on a different thread is a fork wearing a resume's name — +// recording it would make the durable handle chain lie about what this session +// actually proved. + +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts' + +export type CodexOpenedThread = { + threadId: string + thread?: Record + /** Rollout file Codex named, when it named one. */ + historyPath: string | null + model?: string + effort?: string +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +export async function openCodexThread( + connection: CodexAppServerConnection, + launch: { cwd: string; resumeThreadId: string | null; resumePath?: string | null }, + timeoutMs: number | undefined +): Promise { + const opened = await connection.request( + launch.resumeThreadId ? 'thread/resume' : 'thread/start', + launch.resumeThreadId + ? { + threadId: launch.resumeThreadId, + cwd: launch.cwd, + ...(launch.resumePath ? { path: launch.resumePath } : {}) + } + : { cwd: launch.cwd }, + { timeoutMs } + ) + const threadId = readCodexThreadId(opened) + if (!threadId) { + throw new Error('codex app-server did not name the thread it opened') + } + if (launch.resumeThreadId && threadId !== launch.resumeThreadId) { + throw new Error(`codex app-server resumed ${threadId} instead of ${launch.resumeThreadId}`) + } + const result = opened as Record + const thread = + typeof result.thread === 'object' && result.thread !== null + ? (result.thread as Record) + : {} + const model = nonEmptyString(result.model) + const effort = nonEmptyString(result.reasoningEffort) + return { + threadId, + thread, + historyPath: readCodexThreadPath(opened), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + } +} diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts new file mode 100644 index 00000000000..1f31197e8d5 --- /dev/null +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -0,0 +1,154 @@ +import { + isCodexAppServerRequestError, + type CodexAppServerConnection +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import type { + CodexSession, + CodexStructuredSessionAdapterDeps, + CodexStructuredSessionEvent +} from './codex-structured-session-state' +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { + captureCodexTurnProcesses, + terminateCodexTurnProcesses, + type CodexTurnProcessSnapshot +} from './codex-structured-turn-processes' + +type TurnProcessState = { + baseline: Promise + blockedCompletions: Set + deferredCompletions: Map +} + +type TurnCancellationDeps = Pick< + CodexStructuredSessionAdapterDeps, + 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' +> & { + emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void +} + +export class CodexStructuredTurnCancellation { + private readonly states = new WeakMap() + + constructor(private readonly deps: TurnCancellationDeps) {} + + register(session: CodexSession): void { + this.states.set(session, { + baseline: Promise.resolve(null), + blockedCompletions: new Set(), + deferredCompletions: new Map() + }) + } + + captureBaseline(session: CodexSession): Promise { + this.refreshBaseline(session) + return this.state(session).baseline + } + + handleNotification( + sessionId: string, + session: CodexSession, + method: string, + params: unknown + ): boolean { + const threadId = readCodexThreadId(params) ?? session.threadId + if (method !== 'turn/completed' || threadId !== session.threadId) { + return false + } + const turnId = readCodexTurnId(params) + const state = this.state(session) + if (!turnId || !state.blockedCompletions.has(turnId)) { + return false + } + const event = { + type: 'notification' as const, + sessionId, + threadId, + method, + params + } + state.deferredCompletions.set(turnId, event) + return true + } + + async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> { + const state = this.state(session) + state.blockedCompletions.add(turnId) + const baseline = await state.baseline + let requestError: unknown + const interruptReceipt = session.connection + .request( + 'turn/interrupt', + { threadId: session.threadId, turnId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + .then( + () => true, + (error: unknown) => { + requestError = error + return false + } + ) + const [acknowledged, terminated] = await Promise.all([ + interruptReceipt, + this.terminate(session.connection, baseline) + ]) + if (terminated && acknowledged) { + this.releaseCompletion(session, turnId) + return { cancelled: true } + } + if ( + requestError && + !isCodexAppServerRequestError(requestError) && + !isCodexAppServerUnsupportedError(requestError) + ) { + this.releaseCompletion(session, turnId) + throw requestError + } + // A failed cancellation must not permanently divert the provider's later + // completion for this turn. Let the normal completion path settle it. + this.releaseCompletion(session, turnId) + return { cancelled: false } + } + + private capture(pid: number | undefined): Promise { + return pid + ? (this.deps.captureTurnProcesses ?? captureCodexTurnProcesses)(pid) + : Promise.resolve(null) + } + + private terminate( + connection: Pick, + baseline: CodexTurnProcessSnapshot | null + ): Promise { + return connection.pid + ? (this.deps.terminateTurnProcesses ?? terminateCodexTurnProcesses)(connection.pid, baseline) + : Promise.resolve(false) + } + + private refreshBaseline(session: CodexSession): void { + this.state(session).baseline = this.capture(session.connection.pid) + } + + private releaseCompletion( + session: CodexSession, + turnId: string, + completion = this.state(session).deferredCompletions.get(turnId) + ): void { + const state = this.state(session) + state.blockedCompletions.delete(turnId) + state.deferredCompletions.delete(turnId) + if (completion) { + this.deps.emit(session, completion) + } + } + + private state(session: CodexSession): TurnProcessState { + const state = this.states.get(session) + if (!state) { + throw new Error('codex turn process state is unavailable') + } + return state + } +} diff --git a/src/main/codex/codex-structured-turn-processes.integration.test.ts b/src/main/codex/codex-structured-turn-processes.integration.test.ts new file mode 100644 index 00000000000..2a63c8099ea --- /dev/null +++ b/src/main/codex/codex-structured-turn-processes.integration.test.ts @@ -0,0 +1,103 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { + captureCodexTurnProcesses, + terminateCodexTurnProcesses +} from './codex-structured-turn-processes' + +function nextLine(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let buffer = '' + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + child.stdout.off('data', onData) + resolve(buffer.slice(0, newline)) + } + child.once('error', reject) + child.stdout.on('data', onData) + }) +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe.runIf(process.platform !== 'win32')('Codex structured turn process termination', () => { + it('removes the exact PID of a stopped 60-second command', async () => { + const root = spawn( + process.execPath, + [ + '-e', + `const { spawn } = require('node:child_process'); + process.stdin.once('data', () => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(child.pid) + '\\n'); + }); + setTimeout(() => {}, 60000);` + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let commandPid = 0 + try { + const baseline = await captureCodexTurnProcesses(root.pid!) + root.stdin.write('start\n') + commandPid = Number(await nextLine(root)) + expect(processExists(commandPid)).toBe(true) + + await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) + expect(processExists(commandPid)).toBe(false) + } finally { + if (commandPid > 0 && processExists(commandPid)) { + process.kill(commandPid, 'SIGKILL') + } + root.kill('SIGKILL') + } + }, 15_000) + + it('preserves descendants that predate the turn', async () => { + const root = spawn( + process.execPath, + [ + '-e', + `const { spawn } = require('node:child_process'); + const persistent = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(persistent.pid) + '\\n'); + process.stdin.once('data', () => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(child.pid) + '\\n'); + }); + setTimeout(() => {}, 60000);` + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let persistentPid = 0 + let commandPid = 0 + try { + persistentPid = Number(await nextLine(root)) + const baseline = await captureCodexTurnProcesses(root.pid!) + root.stdin.write('start\n') + commandPid = Number(await nextLine(root)) + + await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) + expect(processExists(persistentPid)).toBe(true) + expect(processExists(commandPid)).toBe(false) + } finally { + if (persistentPid > 0 && processExists(persistentPid)) { + process.kill(persistentPid, 'SIGKILL') + } + if (commandPid > 0 && processExists(commandPid)) { + process.kill(commandPid, 'SIGKILL') + } + root.kill('SIGKILL') + } + }, 15_000) +}) diff --git a/src/main/codex/codex-structured-turn-processes.ts b/src/main/codex/codex-structured-turn-processes.ts new file mode 100644 index 00000000000..f69c0455a2e --- /dev/null +++ b/src/main/codex/codex-structured-turn-processes.ts @@ -0,0 +1,85 @@ +import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' +import { queryWindowsProcessDescendants } from '../providers/windows-foreground-process-rows' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' + +export type CodexTurnProcessSnapshot = + | { platform: 'posix'; snapshot: DescendantSnapshot } + | { platform: 'win32'; identities: ReadonlyMap } + +function windowsIdentity(row: { + ppid: number + name: string + command: string + executablePath?: string +}): string { + return [row.ppid, row.name, row.command, row.executablePath ?? ''].join('\0') +} + +export async function captureCodexTurnProcesses( + rootPid: number +): Promise { + if (process.platform === 'win32') { + const descendants = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + return descendants + ? { + platform: 'win32', + identities: new Map(descendants.map((row) => [row.pid, windowsIdentity(row)])) + } + : null + } + const snapshot = await captureDescendantSnapshot(rootPid) + return snapshot ? { platform: 'posix', snapshot } : null +} + +function addedPosixDescendants( + baseline: DescendantSnapshot, + current: DescendantSnapshot +): DescendantSnapshot { + const baselineRows = new Map(baseline.descendants.map((row) => [row.pid, row])) + return { + ...current, + descendants: current.descendants.filter((row) => { + const prior = baselineRows.get(row.pid) + return prior?.startedAt !== row.startedAt || prior.pgid !== row.pgid + }) + } +} + +async function terminateWindowsAddedProcesses( + rootPid: number, + baseline: ReadonlyMap +): Promise { + const current = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + if (!current) { + return false + } + const added = current.filter((row) => baseline.get(row.pid) !== windowsIdentity(row)) + const addedPids = new Set(added.map((row) => row.pid)) + const roots = added.filter((row) => !addedPids.has(row.ppid)) + await Promise.all(roots.map((row) => terminateWindowsProcessTree(row.pid))) + const targetIdentities = new Map(added.map((row) => [row.pid, windowsIdentity(row)])) + const remaining = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + return ( + remaining !== null && + remaining.every((row) => targetIdentities.get(row.pid) !== windowsIdentity(row)) + ) +} + +export async function terminateCodexTurnProcesses( + rootPid: number, + baseline: CodexTurnProcessSnapshot | null +): Promise { + if (!baseline) { + return false + } + if (baseline.platform === 'win32') { + return terminateWindowsAddedProcesses(rootPid, baseline.identities) + } + const current = await captureDescendantSnapshot(rootPid) + if (!current) { + return false + } + const added = addedPosixDescendants(baseline.snapshot, current) + return terminateDescendantSnapshotAndWait(added) +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts new file mode 100644 index 00000000000..ffe4c850d5c --- /dev/null +++ b/src/main/codex/codex-structured-turn-start.ts @@ -0,0 +1,128 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + isCodexAppServerRequestError, + type CodexAppServerConnection +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { readCodexTurnId } from './codex-structured-thread-facts' + +// Starting a Codex turn and learning its id, which are not the same event: +// `turn/start` returns the id on newer builds and acks before it exists on +// older ones, where it arrives as a `turn/started` notification instead. + +/** Codex records the user message first in a turn, so the submission Orca just + * accepted is ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + +/** Past this the turn is real but unnameable, which the journal renders as + * delivery unconfirmed rather than failure. */ +const TURN_ID_WAIT_MS = 10_000 + +/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise + * become an arbitrary client-controlled `turn/start` parameter. */ +const CODEX_TURN_OPTION_KEYS = new Set([ + 'model', + 'effort', + 'approvalPolicy', + 'approvalsReviewer', + 'personality', + 'serviceTier' +]) + +export function isCodexTurnOptionKey(key: string): boolean { + return CODEX_TURN_OPTION_KEYS.has(key) +} + +/** The session state one turn needs. `turnIdWaiters` is shared with the + * notification handler, which resolves the head of the queue — correct because + * Codex runs one turn per thread, so starts and `turn/started` share an order. */ +export type CodexTurnHost = { + connection: Pick + threadId: string + options: Map + turnIdWaiters: ((turnId: string) => void)[] +} + +function turnInputFor(body: AgentJournalMessageItem): Record[] { + const input: Record[] = [] + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + input.push({ type: 'text', text: block.text }) + } else if (block.type === 'image-ref' && block.path) { + input.push({ type: 'localImage', path: block.path }) + } else if (block.type === 'image-ref' && block.url) { + input.push({ type: 'image', url: block.url }) + } + } + return input +} + +/** + * Resolves the turn id, or null when Codex owns a turn it never named. Throws + * only for outcomes the wire must not read as acceptance. + */ +export async function startCodexTurn( + host: CodexTurnHost, + input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } +): Promise { + // Registered BEFORE the call: on builds that ack first, `turn/started` can + // land while the response is still in flight. + let notified: ((turnId: string) => void) | null = null + const fromNotification = new Promise((resolve) => { + notified = resolve + host.turnIdWaiters.push(resolve) + setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.() + }) + try { + const started = await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + ...Object.fromEntries(host.options) + }, + { timeoutMs: input.timeoutMs } + ) + return readCodexTurnId(started) ?? (await fromNotification) + } finally { + const index = notified ? host.turnIdWaiters.indexOf(notified) : -1 + if (index !== -1) { + host.turnIdWaiters.splice(index, 1) + } + } +} + +/** + * One submission's outcome as the wire must read it: accepted names the turn, + * rejected is Codex answering and declining, and unknown covers a turn that is + * real but unnameable — never a failure the user is told their message hit. + */ +export async function dispatchCodexTurn( + session: CodexTurnHost, + input: { clientMessageId: string; body: AgentJournalMessageItem }, + timeoutMs: number | undefined +): Promise { + let turnId: string | null + try { + turnId = await startCodexTurn(session, { ...input, timeoutMs }) + } catch (error) { + if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + return { state: 'rejected', reason: (error as Error).message } + } + throw error + } + return turnId === null + ? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' } + : { + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: session.threadId, + turnId, + ordinal: CODEX_USER_MESSAGE_ORDINAL + } + } +} diff --git a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..cada4249591 --- /dev/null +++ b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,160 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { openCodexThread } from './codex-structured-thread-open' +import { proveCodexTuiRollout } from './codex-tui-rollout-proof' + +const codexCommand = resolveCodexCommand() +const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0 +const itWithCodex = codexAvailable ? it : it.skip +const tempHomes: string[] = [] + +async function waitForTuiStart(proc: pty.IPty): Promise { + let output = '' + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)), + 15_000 + ) + proc.onData((data) => { + output += data + if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) { + clearTimeout(timeout) + resolve(output) + } + }) + proc.onExit(({ exitCode }) => { + clearTimeout(timeout) + reject( + new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`) + ) + }) + }) +} + +async function waitForRollout(path: string, threadId: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const rollout = await readFile(path, 'utf8') + if (rollout.includes(threadId)) { + return rollout + } + } catch { + // The rollout is created asynchronously after thread/start. + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Codex did not materialize the resumed rollout') +} + +afterEach(async () => { + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +describe('real Codex structured-to-TUI resume', () => { + itWithCodex( + 'resumes the exact isolated rollout and reaches the initial TUI screen', + async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-')) + tempHomes.push(codexHome) + await writeFile( + join(codexHome, 'config.toml'), + [ + 'model_provider = "orca-integration"', + 'model = "gpt-5"', + '', + '[model_providers.orca-integration]', + 'name = "Orca integration"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + `[projects.${JSON.stringify(process.cwd())}]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + const connection = await openCodexAppServerConnection({ + command: codexCommand, + args: ['app-server'], + env: { CODEX_HOME: codexHome } + }) + const opened = await openCodexThread( + connection, + { cwd: process.cwd(), resumeThreadId: null }, + 15_000 + ) + await connection.request( + 'turn/start', + { + threadId: opened.threadId, + clientUserMessageId: 'real-binary-resume-fixture', + input: [{ type: 'text', text: 'materialize the isolated resume fixture' }] + }, + { timeoutMs: 15_000 } + ) + expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId) + await connection.close() + + expect(opened.historyPath).toContain(opened.threadId) + expect(opened.historyPath).toContain(join(codexHome, 'sessions')) + const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + CODEX_HOME: codexHome, + ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof', + TERM: 'xterm-256color' + } + }) + const tuiExit = new Promise((resolve) => + tui.onExit(({ exitCode }) => resolve(exitCode)) + ) + const kittyKeyboard = new TerminalKittyKeyboardModeTracker() + let tuiOutput = '' + let lastOutputAt: number | null = null + tui.onData((data) => { + tuiOutput += data + lastOutputAt = Date.now() + kittyKeyboard.scan(data) + }) + try { + await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i) + const proof = await proveCodexTuiRollout({ + codexHome, + threadId: opened.threadId, + kittyKeyboardFlags: kittyKeyboard.flags, + readOutput: () => ({ text: tuiOutput, lastOutputAt }), + write: (data) => { + tui.write(data) + return true + } + }) + expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!)) + } finally { + try { + tui.kill() + } catch { + // Already exited. + } + await Promise.race([ + tuiExit, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000) + ) + ]) + } + }, + 30_000 + ) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts new file mode 100644 index 00000000000..2de66c0dc71 --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -0,0 +1,205 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + codexTuiStatusProbeInput, + parseCodexTuiStatusSessionId, + proveCodexTuiRollout, + resolveLiveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from './codex-tui-rollout-proof' + +const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' +const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' + +describe('Codex TUI rollout proof', () => { + it('parses the exact session shown by status', () => { + expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() + }) + + it('parses the Codex 0.148 status screen and semantic thread labels', () => { + const status = [ + '\u001b[2m│ >_ OpenAI Codex (v0.148.0) │', + '│ Model: gpt-5.6-sol (reasoning high, summaries auto) │', + `│ Session: \u001b[22m${THREAD}\u001b[2m │` + ].join('\n') + + expect(parseCodexTuiStatusSessionId(status)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Thread ID: ${OTHER_THREAD}`)).toBe(OTHER_THREAD) + }) + + it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { + expect(codexTuiStatusProbeInput(0)).toEqual({ + command: '\u001b[200~/status\u001b[201~', + submit: '\r' + }) + expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u') + expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u') + }) + + it('resolves only the exact session_meta rollout under the pinned account home', async () => { + const files = async function* (): AsyncGenerator { + yield '/pinned/sessions/scratch/rollout-wrong.jsonl' + yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`) + expect(readSessionMetaId).toHaveBeenCalledTimes(1) + }) + + it('accepts Codex rollout ids with a distinct rollout suffix', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}_019fd900-77aa-7c19-8bd0-2b3c4d5e6f71.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toContain(`rollout-now-${THREAD}_`) + }) + + it('skips a rollout file that vanishes mid-scan instead of aborting the proof', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-rollout-proof-')) + try { + const day = join(root, 'sessions', '2026', '08', '11') + await mkdir(day, { recursive: true }) + const real = join(day, `rollout-now-${THREAD}.jsonl`) + await writeFile( + real, + `${JSON.stringify({ type: 'session_meta', payload: { id: THREAD } })}\n` + ) + // Listed but already deleted by the time the scan reads it — Codex prunes + // and rewrites rollout files while the scan runs. + const vanished = join(day, `rollout-gone-${THREAD}.jsonl`) + const files = async function* (): AsyncGenerator { + yield vanished + yield real + } + + await expect( + resolvePinnedCodexRolloutProof(root, THREAD, { listFiles: files }) + ).resolves.toBe(real) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects a rollout whose session_meta names another thread', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { + listFiles: files, + readSessionMetaId: async () => OTHER_THREAD + }) + ).resolves.toBeNull() + }) + + it('rejects a different status session after filesystem proof', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: `Session: ${OTHER_THREAD}`, + lastOutputAt: reads++ > 0 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).rejects.toThrow('resumed a different Codex session') + expect(write).toHaveBeenCalledTimes(2) + }) + + it('dismisses status only after the exact session is observed', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) + + it('discovers the live thread and resolves its pinned rollout', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => '/pinned/sessions/rollout.jsonl') + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD, transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(resolveRollout).toHaveBeenCalledWith('/pinned', THREAD) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\r', + '\u001b' + ]) + }) + + it('accepts a proven blank Codex 0.148 session before its lazy rollout exists', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => null) + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: + reads++ > 0 + ? `│ >_ OpenAI Codex (v0.148.0) │\n│ Session: \u001b[22m${THREAD}\u001b[2m │` + : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD }) + expect(resolveRollout).toHaveBeenCalledTimes(5) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts new file mode 100644 index 00000000000..d9de2227231 --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -0,0 +1,247 @@ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + +const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const STATUS_SESSION_RE = new RegExp( + `\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, + 'gi' +) +const ROLLOUT_READ_LIMIT = 64 * 1024 +const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' +const KITTY_ENTER = '\u001b[13u' +const TAB = '\t' + +export type CodexTuiProofOutput = { + text: string + lastOutputAt: number | null +} + +export type CodexTuiRolloutProofOptions = { + listFiles?: (sessionsRoot: string) => AsyncIterable + readSessionMetaId?: (filePath: string) => Promise +} + +export function parseCodexTuiStatusSessionId(output: string): string | null { + let sessionId: string | null = null + for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) { + sessionId = match[1] ?? null + } + return sessionId +} + +export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string { + return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r' +} + +export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): { + command: string + submit: string +} { + return { + command: STATUS_COMMAND_PASTE, + submit: codexTuiStatusSubmitInput(kittyKeyboardFlags) + } +} + +export async function resolvePinnedCodexRolloutProof( + codexHome: string, + threadId: string, + options: CodexTuiRolloutProofOptions = {} +): Promise { + const sessionsRoot = join(codexHome, 'sessions') + const listFiles = + options.listFiles ?? + ((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 })) + const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId + const expectedThreadSegment = `-${threadId.toLowerCase()}` + + for await (const filePath of listFiles(sessionsRoot)) { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/') + if ( + !relativePath || + !/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) || + !(() => { + const lower = relativePath.toLowerCase() + const marker = lower.lastIndexOf(expectedThreadSegment) + if (marker === -1) { + return false + } + const after = lower.slice(marker + expectedThreadSegment.length) + return after === '.jsonl' || (after.startsWith('_') && after.endsWith('.jsonl')) + })() + ) { + continue + } + if ((await readSessionMetaId(filePath)) === threadId) { + return filePath + } + } + return null +} + +export async function proveCodexTuiRollout(input: { + codexHome: string + threadId: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ transcriptPath: string }> { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const proofDeadline = Date.now() + (input.timeoutMs ?? 15_000) + let transcriptPath: string | null = null + // Codex can rotate or finish flushing the rollout while the resumed TUI is + // starting. Keep the proof retryable inside the same bounded deadline. + while (!transcriptPath && Date.now() < proofDeadline) { + transcriptPath = await resolveRollout(input.codexHome, input.threadId) + if (!transcriptPath) { + await delay(Math.min(100, Math.max(1, proofDeadline - Date.now()))) + } + } + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = proofDeadline + const retrySubmitAt = Date.now() + 750 + let retriedSubmit = false + while (Date.now() < deadline) { + const output = input.readOutput() + if ( + !retriedSubmit && + Date.now() >= retrySubmitAt && + output.text.includes('/status') && + !parseCodexTuiStatusSessionId(output.text) + ) { + retriedSubmit = true + // Newer Codex builds keep the slash-command popup open after a bracketed + // paste. Tab commits the highlighted command as text; the following Enter + // then dispatches it instead of merely selecting the popup row. + if (!input.write(TAB)) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + } + if (output.lastOutputAt !== baselineOutputAt) { + const observedThreadId = parseCodexTuiStatusSessionId(output.text) + if (observedThreadId && observedThreadId !== input.threadId) { + throw new Error('The agent terminal resumed a different Codex session.') + } + if (observedThreadId === input.threadId) { + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { transcriptPath } + } + } + await delay(100) + } + throw new Error('The agent terminal did not prove the expected Codex rollout.') +} + +export async function resolveLiveCodexTuiRollout(input: { + codexHome: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ threadId: string; transcriptPath?: string }> { + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const output = input.readOutput() + if (output.lastOutputAt !== baselineOutputAt) { + const threadId = parseCodexTuiStatusSessionId(output.text) + if (threadId) { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + let transcriptPath: string | null = null + // Codex 0.148 allocates a session before it writes a rollout; give a just-written + // file a short visibility window without rejecting a genuinely blank conversation. + for (let attempt = 0; attempt < 5 && !transcriptPath; attempt += 1) { + transcriptPath = await resolveRollout(input.codexHome, threadId) + if (!transcriptPath && attempt < 4) { + await delay(100) + } + } + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { threadId, ...(transcriptPath ? { transcriptPath } : {}) } + } + } + await delay(100) + } + throw new Error('The agent terminal did not publish a resumable Codex conversation.') +} + +async function readCodexRolloutSessionMetaId(filePath: string): Promise { + // A listed rollout may vanish before it is read — Codex prunes and rewrites + // these files. One missing file must not abort the whole scan. + let file: Awaited> + try { + file = await open(filePath, 'r') + } catch { + return null + } + try { + const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + const record = JSON.parse(firstLine) as { + type?: unknown + id?: unknown + session_id?: unknown + thread_id?: unknown + payload?: { id?: unknown; session_id?: unknown; thread_id?: unknown } + } + if (record.type !== 'session_meta') { + return null + } + const id = + record.payload?.id ?? + record.payload?.session_id ?? + record.payload?.thread_id ?? + record.id ?? + record.session_id ?? + record.thread_id + return typeof id === 'string' && id.length > 0 ? id : null + } catch { + return null + } finally { + await file.close() + } +} diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index b61e35f78e5..42d728bc994 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -55,6 +55,7 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti admission.admit({ id: session.sessionId, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.pid ? { rootProcessId: session.pid } : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', title: 'shell', diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 84ff1adce4c..2f40b0881e8 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,7 +4,7 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { open, readdir, rename, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils' @@ -52,6 +52,61 @@ export async function renameDurable(tmpPath: string, finalPath: string): Promise await syncDirectory(dirname(finalPath)) } +/** + * Write `payload` to `tmpPath` and fsync it, WITHOUT publishing it. For callers that must order + * other work between "the new content is durable" and "the new content is visible" — a backup + * rotation that has to happen while the old file is still in place, for instance. + */ +export async function writeTempFileDurable( + tmpPath: string, + payload: string, + mode?: number +): Promise { + const handle = await open(tmpPath, 'w', mode) + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } +} + +/** + * Copy `sourcePath` onto `finalPath` durably: a fresh inode, fsynced, then renamed into place. A + * plain copyFile can be interrupted and leave a torn destination — fatal when the destination is + * the backup someone will fall back to. Returns false when the source does not exist. + */ +export async function copyFileDurable(sourcePath: string, finalPath: string): Promise { + const tmpPath = durableWriteTempPath(finalPath) + let renamed = false + try { + try { + // copyFile stays in the kernel — and clones the extents outright on APFS and btrfs — so + // this does not pull the whole file through the process on every commit. + await copyFile(sourcePath, tmpPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + const handle = await open(tmpPath, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + await rename(tmpPath, finalPath) + renamed = true + await syncDirectory(dirname(finalPath)) + return true + } finally { + if (!renamed) { + await rm(tmpPath, { force: true }).catch(() => {}) + } + } +} + /** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */ export async function writeFileDurable( tmpPath: string, @@ -75,14 +130,8 @@ export async function writeFileDurableIfCurrent( ): Promise { let renamed = false try { - const handle = await open(tmpPath, 'w') - try { - await handle.writeFile(payload, 'utf-8') - // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. - await handle.sync() - } finally { - await handle.close() - } + // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. + await writeTempFileDurable(tmpPath, payload) if (!isCurrent()) { return false } diff --git a/src/main/index.ts b/src/main/index.ts index 9e6097d18c5..89e3cbf0aca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -335,6 +335,7 @@ import { AgentAwakeService } from './agent-awake-service' import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' +import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' import { quitTeardownStartGate } from './quit-teardown-start-gate' import { beginSshShutdown } from './ipc/ssh-shutdown-drain' import { PluginService } from './plugins/plugin-service' @@ -995,6 +996,11 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) + await runtime?.prepareStructuredAgentSessionStartupRestoration() +}) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ firstWindowStartupServicesReady, @@ -2791,6 +2797,11 @@ void app.whenReady().then(async () => { runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, orchestrationEnvironmentTransport, @@ -3527,6 +3538,7 @@ app.on('will-quit', (e) => { pluginMarketplaceInstaller = null const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() @@ -3628,6 +3640,7 @@ app.on('will-quit', (e) => { { name: 'skill-uploads', promise: skillUploadShutdown }, { name: 'grok-hooks', promise: grokHookCleanup }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, { name: 'stats', promise: statsFlush }, { name: 'state', promise: storeFlush } diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index a5618c0041d..e8f56516b08 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -5,8 +5,10 @@ import type { AiVaultSessionResumePreparation } from '../../shared/ai-vault-resume-preparation' import { parseExecutionHostId } from '../../shared/execution-host' +import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership' export type AiVaultResumeHandlerOptions = { + ensureStructuredSessionOwnership?: () => Promise prepareSessionResume?: AiVaultSessionResumePreparation prepareRuntimeSessionResume?: ( environmentId: string, @@ -24,6 +26,8 @@ export async function prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, options: AiVaultResumeHandlerOptions ): Promise { + await options.ensureStructuredSessionOwnership?.() + assertLegacyAiVaultResumeAllowed(args) const executionHost = parseExecutionHostId(args.executionHostId) if (executionHost?.kind === 'runtime') { if (!options.prepareRuntimeSessionResume) { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 198c5295662..746f469dba7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -57,6 +57,7 @@ import { resolveAiVaultSessionTitlesByHost, type RuntimeAiVaultSessionTitleResolver } from './ai-vault-session-title-routing' +import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 // Why: a remote home with many agent roots routinely needs seconds to walk, @@ -282,7 +283,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo : undefined const controller = listCancellations.begin(event, requestToken) try { - return await listAiVaultSessions(args, { signal: controller?.signal }) + await handlerOptions.ensureStructuredSessionOwnership?.() + const result = await listAiVaultSessions(args, { signal: controller?.signal }) + return projectStructuredAiVaultSessions(result, true) } catch (error) { // Why: superseding a scan is normal control flow, but Electron logs every // rejected handler — report it as a result so the log stays truthful. @@ -317,8 +320,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo handleAiVaultGetFirstUserPrompt(args) ) registerAiVaultDeleteHandler(aiVaultDeleteDeps) - // DOM focus/visibility events don't fire in the renderer on macOS app - // activation, so refresh-on-refocus needs this main-process signal. + // macOS app activation skips DOM focus events, so emit the refresh signal here. app.on('browser-window-focus', (_event, window) => { if (!window.isDestroyed()) { window.webContents.send('aiVault:windowFocused') diff --git a/src/main/ipc/desktop-runtime-sender-lifecycle.ts b/src/main/ipc/desktop-runtime-sender-lifecycle.ts new file mode 100644 index 00000000000..b00ab4711fb --- /dev/null +++ b/src/main/ipc/desktop-runtime-sender-lifecycle.ts @@ -0,0 +1,72 @@ +import type { WebContents } from 'electron' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type SenderState = { + connectionId: string + sender: WebContents + subscriptions: Map +} + +type CleanupRuntime = Pick + +export class DesktopRuntimeSenderLifecycle { + private readonly senders = new Map() + private nextConnectionGeneration = 1 + + constructor(private readonly runtime: CleanupRuntime) {} + + connectionIdFor(sender: WebContents): string { + return this.stateFor(sender).connectionId + } + + subscriptionsFor(sender: WebContents): Map { + return this.stateFor(sender).subscriptions + } + + existingSubscriptionsFor(sender: WebContents): Map | null { + const state = this.senders.get(sender.id) + return state?.sender === sender ? state.subscriptions : null + } + + private stateFor(sender: WebContents): SenderState { + const existing = this.senders.get(sender.id) + if (existing?.sender === sender) { + return existing + } + if (existing) { + this.retire(existing, true) + } + const state: SenderState = { + connectionId: this.mintConnectionId(sender.id), + sender, + subscriptions: new Map() + } + this.senders.set(sender.id, state) + const retireDocument = (): void => this.retire(state, false) + sender.on('did-navigate', retireDocument) + sender.on('render-process-gone', retireDocument) + sender.once('destroyed', () => this.retire(state, true)) + return state + } + + private retire(state: SenderState, destroyed: boolean): void { + if (this.senders.get(state.sender.id) !== state) { + return + } + const retiredConnectionId = state.connectionId + if (destroyed) { + this.senders.delete(state.sender.id) + } else { + state.connectionId = this.mintConnectionId(state.sender.id) + } + for (const controller of state.subscriptions.values()) { + controller.abort() + } + state.subscriptions.clear() + this.runtime.cleanupSubscriptionsForConnection(retiredConnectionId) + } + + private mintConnectionId(senderId: number): string { + return `desktop-renderer:${senderId}:${this.nextConnectionGeneration++}` + } +} diff --git a/src/main/ipc/pty-agent-session-write-gate.test.ts b/src/main/ipc/pty-agent-session-write-gate.test.ts new file mode 100644 index 00000000000..091d5eb4474 --- /dev/null +++ b/src/main/ipc/pty-agent-session-write-gate.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: true, + getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata') + }, + ipcMain: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + }, + powerMonitor: { on: vi.fn() } +})) + +vi.mock('fs', () => ({ + existsSync: () => true, + statSync: () => ({ isDirectory: () => true, mode: 0o755 }), + accessSync: () => undefined, + mkdirSync: vi.fn(), + readFileSync: vi.fn(() => ''), + writeFileSync: vi.fn(), + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) + +vi.mock('node-pty', () => ({ + spawn: vi.fn().mockReturnValue({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: 'zsh', + pid: 12345 + }) +})) + +vi.mock('../opencode/hook-service', () => ({ + openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +vi.mock('../pi/titlebar-extension-service', () => ({ + piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +import { + deletePtyOwnership, + registerPtyHandlers, + registerSshPtyProvider, + setPtyOwnership, + unregisterSshPtyProvider +} from './pty' +import { agentSessionPtyWriteGate } from '../runtime/agent-session-pty-write-gate' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import type { IPtyProvider } from '../providers/types' +import { setPtyHostBindings } from './pty-host-bindings' + +// The renderer IPC path and the runtime controller are the two byte entry points this module owns; +// both are proved to consult the lease, and both are proved to leave an unbound PTY alone. + +const CONNECTION_ID = 'conn-lease' +const PTY_ID = 'ssh:conn-lease@@pty-1' +const SESSION_ID = 'session-alpha-1' + +const handlers = new Map unknown>() +const records = new Map() + +const mainWindow = { + isDestroyed: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } +} +const mainWindowIpcEvent = { sender: mainWindow.webContents } + +let ptyController: { write: (ptyId: string, data: string) => boolean } | null = null + +function createMockProvider(): IPtyProvider { + return { + spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + listProcesses: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } as unknown as IPtyProvider +} + +let provider: IPtyProvider + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { + publish(lease) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) +} + +function writeFromRenderer(data: string): void { + ;(handlers.get('pty:write') as (event: unknown, args: unknown) => void)(mainWindowIpcEvent, { + id: PTY_ID, + data + }) +} + +async function writeAcceptedFromRenderer(data: string): Promise { + return await ( + handlers.get('pty:writeAccepted') as ( + event: unknown, + args: unknown + ) => boolean | Promise + )(mainWindowIpcEvent, { id: PTY_ID, data }) +} + +function lastRefusal(): { id: string; agentSessionRefusal?: { code: string } } | null { + const call = mainWindow.webContents.send.mock.calls.findLast( + ([channel]) => channel === 'pty:writeUnavailable' + ) + return (call?.[1] as { id: string; agentSessionRefusal?: { code: string } }) ?? null +} + +beforeEach(() => { + handlers.clear() + records.clear() + handleMock.mockReset() + onMock.mockReset() + mainWindow.webContents.send.mockReset() + ptyController = null + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + setPtyHostBindings({ + ipc: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } as never + }) + const runtime = { + setPtyController: (controller: { write: (ptyId: string, data: string) => boolean }) => { + ptyController = controller + }, + getDriver: () => ({ kind: 'desktop' }) + } + registerPtyHandlers(mainWindow as never, runtime as never) + provider = createMockProvider() + registerSshPtyProvider(CONNECTION_ID, provider) + setPtyOwnership(PTY_ID, CONNECTION_ID) +}) + +afterEach(() => { + setPtyHostBindings({}) + agentSessionPtyWriteGate.detachRecordLookup() + deletePtyOwnership(PTY_ID) + unregisterSshPtyProvider(CONNECTION_ID) +}) + +describe('renderer IPC write path', () => { + it('writes an unbound pty unchanged, which is every shell that exists today', () => { + writeFromRenderer('ls') + + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') + expect(lastRefusal()).toBeNull() + }) + + it('writes when the TUI owner holds a proven-live lease', () => { + enforce() + + writeFromRenderer('ls') + + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('refuses and reports the owner when native chat holds the session', () => { + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + writeFromRenderer('ls') + + expect(provider.write).not.toHaveBeenCalled() + const refusal = lastRefusal() + expect(refusal?.id).toBe(PTY_ID) + expect(refusal?.agentSessionRefusal).toMatchObject({ + code: 'agent_session_conflict', + sessionId: SESSION_ID, + ownerRuntimeKind: 'native', + ownerPid: 4242 + }) + }) + + it('refuses while the lease is unreconciled after a host restart', () => { + enforce(agentSessionLeaseFixture({ unreconciled: true })) + + writeFromRenderer('ls') + + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('execution_owner_reconciling') + }) + + it('refuses the acknowledged write path too', async () => { + enforce(agentSessionLeaseFixture({ handoffStage: 'preparing' })) + + await expect(writeAcceptedFromRenderer('')).resolves.toBe(false) + + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') + }) + + it('leaves the acknowledged write path silent for an unbound pty', async () => { + await writeAcceptedFromRenderer('') + + expect(lastRefusal()).toBeNull() + }) + + it('stops a chunked paste once the fence advances mid-flight', async () => { + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + vi.mocked(provider.write).mockImplementation(() => { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + }) + + writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) + await vi.waitFor(() => expect(lastRefusal()).not.toBeNull()) + + expect(provider.write).toHaveBeenCalledTimes(1) + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_checkpoint_stale') + }) + + it('lets a chunked paste finish while the same owner holds the fence', async () => { + enforce() + + writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) + await vi.waitFor(() => expect(provider.write).toHaveBeenCalledTimes(3)) + + expect(lastRefusal()).toBeNull() + }) +}) + +describe('runtime controller backstop', () => { + it('lets a runtime write through on an unbound pty', () => { + expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') + }) + + it('blocks a runtime write that never passed a typed gate', () => { + // Query replies, followups, and deliveries reach the provider through this one function. + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + expect(ptyController?.write(PTY_ID, 'reply')).toBe(false) + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') + }) + + it('lets a runtime write through while the TUI owner holds the lease', () => { + enforce() + + expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') + }) +}) diff --git a/src/main/ipc/pty/agent-session-write-refusal-report.ts b/src/main/ipc/pty/agent-session-write-refusal-report.ts new file mode 100644 index 00000000000..488e58825e6 --- /dev/null +++ b/src/main/ipc/pty/agent-session-write-refusal-report.ts @@ -0,0 +1,20 @@ +import type { BrowserWindow } from 'electron' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' + +// Why: a lease refusal is never a silent drop — it rides the existing write-unavailable channel +// with an additive field, so old renderers keep their current behavior and new ones can name the +// owner. See docs/reference/remote-wire-compatibility.md. +export function reportAgentSessionWriteRefusal( + mainWindow: BrowserWindow, + id: string, + refusal: AgentSessionPtyWriteRefusal +): void { + if ( + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id, agentSessionRefusal: refusal }) +} diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index fbfc09d1d4d..f27604dc9f8 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -2,10 +2,15 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } fro import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider } from '../../../providers/types' import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { + agentSessionPtyWriteGate, + type AgentSessionPtyWriteAdmittance +} from '../../../runtime/agent-session-pty-write-gate' import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' +import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { @@ -57,10 +62,34 @@ export function createPtyWriteInput(deps: { mainWindow.webContents.send('pty:writeUnavailable', { id }) } + /** Single lease check for every byte-entry point this module owns. */ + const admitAgentSessionPtyWrite = (id: string): AgentSessionPtyWriteAdmittance | null => { + const admission = agentSessionPtyWriteGate.admit(id) + if (admission.admitted) { + return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } + } + reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) + return null + } + + /** Re-check after a yield: the lease can move to another owner between chunks. */ + const readmitAgentSessionPtyWrite = ( + id: string, + admitted: AgentSessionPtyWriteAdmittance + ): boolean => { + const admission = agentSessionPtyWriteGate.readmit(id, admitted) + if (admission.admitted) { + return true + } + reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) + return false + } + const writePtyProviderInputWithinLimit = ( provider: IPtyProvider, id: string, - data: string + data: string, + admitted: AgentSessionPtyWriteAdmittance ): boolean | Promise => { const chunks = iterateTerminalInputChunks(data) const first = chunks.next() @@ -73,21 +102,27 @@ export function createPtyWriteInput(deps: { provider.write(id, first.value) return true } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value, admitted) } const writePtyProviderInput = ( provider: IPtyProvider, id: string, - data: string + data: string, + admitted: AgentSessionPtyWriteAdmittance ): boolean | Promise => { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data, admitted) } return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .then((result) => { + if (result || !readmitAgentSessionPtyWrite(id, admitted)) { + return false + } + return writePtyProviderInputWithinLimit(provider, id, data, admitted) + }) .catch((error) => { reportUnavailablePtyWrite(id, error) return false @@ -103,12 +138,18 @@ export function createPtyWriteInput(deps: { id: string, chunks: Iterator, firstChunk: string, - secondChunk: string + secondChunk: string, + admitted: AgentSessionPtyWriteAdmittance ): Promise => { try { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } + let first = true while (!chunk.done) { + if (!first && !readmitAgentSessionPtyWrite(id, admitted)) { + return false + } + first = false provider.write(id, chunk.value) if (!nextChunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -152,6 +193,10 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } + const admitted = admitAgentSessionPtyWrite(args.id) + if (!admitted) { + return false + } const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined if (!provider) { return false @@ -163,7 +208,7 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(provider, args.id, args.data, admitted) } catch { return false } @@ -173,6 +218,10 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } + const admitted = admitAgentSessionPtyWrite(args.id) + if (!admitted) { + return false + } // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. if (ptyOwnership.get(args.id) !== null) { return false @@ -188,7 +237,7 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(provider, args.id, args.data, admitted) } catch { return false } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 19adaec77de..f74896776a7 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -27,6 +27,7 @@ import { resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, waitForRendererSerializerFromRuntimeController, + writePtyAgentSessionProofFromRuntimeController, writePtyFromRuntimeController } from './operations' @@ -41,7 +42,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), + write: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data), + writeAgentSessionProof: (ptyId, data, authority) => + writePtyAgentSessionProofFromRuntimeController(ptyId, data, authority), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index bb8032f9cfb..00db3380309 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -18,8 +18,21 @@ import { } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' +import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' +import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean { +export function writePtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + data: string +): boolean { + // Why: the backstop for every runtime write path — query replies, followups, deliveries — + // so a caller that forgets the typed gate still cannot reach a provider. + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (!admission.admitted) { + reportAgentSessionWriteRefusal(deps.mainWindow, ptyId, admission.refusal) + return false + } try { getProviderForPty(ptyId).write(ptyId, data) return true @@ -28,6 +41,21 @@ export function writePtyFromRuntimeController(ptyId: string, data: string): bool } } +export function writePtyAgentSessionProofFromRuntimeController( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } +): boolean { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + return false + } + try { + return getProviderForPty(ptyId).write(ptyId, data) !== false + } catch { + return false + } +} + export async function probePtyLivenessFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index a18fbb9a1d3..98cad9b25a6 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -215,6 +215,7 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ + ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/ipc/runtime-subscribe-lifecycle.test.ts b/src/main/ipc/runtime-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..47f9e6afa4d --- /dev/null +++ b/src/main/ipc/runtime-subscribe-lifecycle.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type StreamRecord = { + connectionId: string | undefined + emit: (response: string) => void + settled: boolean + signal: AbortSignal + subscriptionId: string +} + +const { handlers, listeners, streams, unaryConnections } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + streams: [] as StreamRecord[], + unaryConnections: [] as (string | undefined)[] +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }), + removeAllListeners: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../runtime/rpc/dispatcher', () => ({ + RpcDispatcher: class { + dispatch(_request: unknown, options: { connectionId?: string }): Promise { + unaryConnections.push(options.connectionId) + return Promise.resolve({ ok: true, result: {} }) + } + + dispatchStreaming( + request: { id: string }, + emit: (response: string) => void, + options: { connectionId?: string; signal: AbortSignal } + ): Promise { + const record: StreamRecord = { + connectionId: options.connectionId, + emit, + settled: false, + signal: options.signal, + subscriptionId: request.id + } + streams.push(record) + return new Promise((resolve) => { + options.signal.addEventListener('abort', () => { + record.settled = true + resolve() + }) + }) + } + } +})) + +import { registerRuntimeHandlers } from './runtime' + +type SenderHarness = { + destroy: () => void + emitDidNavigate: () => void + emitRenderProcessGone: () => void + listenerCount: (eventName: string) => number + sender: { + id: number + isDestroyed: () => boolean + mainFrame: object + on: (eventName: string, callback: () => void) => void + once: (eventName: string, callback: () => void) => void + send: ReturnType + } +} + +function createSender(id: number): SenderHarness { + const senderListeners = new Map void)[]>() + let destroyed = false + const add = (eventName: string, callback: () => void): void => { + const callbacks = senderListeners.get(eventName) ?? [] + callbacks.push(callback) + senderListeners.set(eventName, callbacks) + } + const fire = (eventName: string): void => { + for (const callback of senderListeners.get(eventName) ?? []) { + callback() + } + } + const mainFrame = {} + return { + destroy: () => { + destroyed = true + fire('destroyed') + }, + emitDidNavigate: () => fire('did-navigate'), + emitRenderProcessGone: () => fire('render-process-gone'), + listenerCount: (eventName) => (senderListeners.get(eventName) ?? []).length, + sender: { + id, + isDestroyed: () => destroyed, + mainFrame, + on: add, + once: (eventName, callback) => { + const wrapped = (): void => { + const callbacks = senderListeners.get(eventName) ?? [] + senderListeners.set( + eventName, + callbacks.filter((candidate) => candidate !== wrapped) + ) + callback() + } + add(eventName, wrapped) + }, + send: vi.fn() + } + } +} + +async function call(sender: SenderHarness['sender']): Promise { + const handler = handlers.get('runtime:call') + if (!handler) { + throw new Error('runtime:call handler not registered') + } + await handler({ sender, senderFrame: sender.mainFrame }, { method: 'agentSession.hold' }) +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const handler = handlers.get('runtime:subscribe') + if (!handler) { + throw new Error('runtime:subscribe handler not registered') + } + handler( + { sender, senderFrame: sender.mainFrame }, + { subscriptionId, method: 'agentSession.watch' } + ) +} + +function streamFor(subscriptionId: string): StreamRecord { + const record = streams.findLast((entry) => entry.subscriptionId === subscriptionId) + if (!record) { + throw new Error(`no stream dispatched for ${subscriptionId}`) + } + return record +} + +const FRAME = JSON.stringify({ ok: true, result: { seq: 1 } }) + +describe('runtime:subscribe renderer lifecycle cleanup', () => { + beforeEach(() => { + handlers.clear() + listeners.clear() + streams.length = 0 + unaryConnections.length = 0 + registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never) + }) + + it('aborts a live stream once its sender commits a navigation', () => { + const harness = createSender(1) + subscribe(harness.sender, 'sub-reload') + const stream = streamFor('sub-reload') + + stream.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-reload', { + ok: true, + result: { seq: 1 } + }) + + harness.emitDidNavigate() + expect(stream.signal.aborted).toBe(true) + expect(stream.settled).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + }) + + it('aborts a live stream when the renderer process dies without destruction', () => { + const harness = createSender(2) + subscribe(harness.sender, 'sub-crash') + const stream = streamFor('sub-crash') + + harness.emitRenderProcessGone() + expect(stream.signal.aborted).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).not.toHaveBeenCalled() + }) + + it('still aborts on sender destruction', () => { + const harness = createSender(3) + subscribe(harness.sender, 'sub-destroyed') + const stream = streamFor('sub-destroyed') + + harness.destroy() + expect(stream.signal.aborted).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).not.toHaveBeenCalled() + }) + + it('scopes navigation cleanup to the navigating sender', () => { + const navigating = createSender(4) + const surviving = createSender(5) + subscribe(navigating.sender, 'sub-navigating') + subscribe(surviving.sender, 'sub-surviving') + + navigating.emitDidNavigate() + + expect(streamFor('sub-navigating').signal.aborted).toBe(true) + expect(streamFor('sub-surviving').signal.aborted).toBe(false) + streamFor('sub-surviving').emit(FRAME) + expect(surviving.sender.send).toHaveBeenCalledTimes(1) + }) + + it('streams to a fresh post-reload subscription while the orphan stays dead', async () => { + const harness = createSender(6) + subscribe(harness.sender, 'sub-old') + const orphan = streamFor('sub-old') + + harness.emitDidNavigate() + expect(orphan.signal.aborted).toBe(true) + // The settled stream's cleanup runs off the dispatch promise. + await Promise.resolve() + + subscribe(harness.sender, 'sub-new') + const fresh = streamFor('sub-new') + expect(fresh.signal.aborted).toBe(false) + + fresh.emit(FRAME) + orphan.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-new', { + ok: true, + result: { seq: 1 } + }) + + // Lifecycle listeners are registered once per sender, not once per subscription. + expect(harness.listenerCount('did-navigate')).toBe(1) + expect(harness.listenerCount('render-process-gone')).toBe(1) + }) + + it('aborts every stream of a sender with several live subscriptions', () => { + const harness = createSender(7) + subscribe(harness.sender, 'sub-a') + subscribe(harness.sender, 'sub-b') + + harness.emitDidNavigate() + + expect(streamFor('sub-a').signal.aborted).toBe(true) + expect(streamFor('sub-b').signal.aborted).toBe(true) + }) + + it('keeps explicit unsubscribe working', () => { + const harness = createSender(8) + subscribe(harness.sender, 'sub-explicit') + const stream = streamFor('sub-explicit') + + const unsubscribe = listeners.get('runtime:unsubscribe') + if (!unsubscribe) { + throw new Error('runtime:unsubscribe listener not registered') + } + unsubscribe({ sender: harness.sender }, { subscriptionId: 'sub-explicit' }) + + expect(stream.signal.aborted).toBe(true) + }) + + it('scopes colliding subscription ids and unsubscribe to their sender', () => { + const firstHarness = createSender(9) + const secondHarness = createSender(10) + subscribe(firstHarness.sender, 'sub-collision') + const first = streams.at(-1)! + subscribe(secondHarness.sender, 'sub-collision') + const second = streams.at(-1)! + + expect(first.signal.aborted).toBe(false) + expect(second.signal.aborted).toBe(false) + + const unsubscribe = listeners.get('runtime:unsubscribe') + if (!unsubscribe) { + throw new Error('runtime:unsubscribe listener not registered') + } + unsubscribe({ sender: firstHarness.sender }, { subscriptionId: 'sub-collision' }) + + expect(first.signal.aborted).toBe(true) + expect(second.signal.aborted).toBe(false) + }) + + it('rotates unary document ownership before sweeping navigation replacements', async () => { + const cleanupSubscriptionsForConnection = vi.fn() + handlers.clear() + listeners.clear() + streams.length = 0 + unaryConnections.length = 0 + registerRuntimeHandlers({ cleanupSubscriptionsForConnection } as never) + const harness = createSender(11) + + await call(harness.sender) + const retired = unaryConnections[0] + harness.emitDidNavigate() + await call(harness.sender) + const replacement = unaryConnections[1] + + expect(retired).toMatch(/^desktop-renderer:11:/) + expect(replacement).toMatch(/^desktop-renderer:11:/) + expect(replacement).not.toBe(retired) + expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(retired) + expect(cleanupSubscriptionsForConnection).not.toHaveBeenCalledWith(replacement) + + harness.emitRenderProcessGone() + expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(replacement) + }) +}) diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index a05dc315bec..dc7f8a01cd7 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, removeHandlerMock, fromWebContentsMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - fromWebContentsMock: vi.fn() -})) +const { handleMock, onMock, removeAllListenersMock, removeHandlerMock, fromWebContentsMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeAllListenersMock: vi.fn(), + removeHandlerMock: vi.fn(), + fromWebContentsMock: vi.fn() + })) vi.mock('electron', () => ({ BrowserWindow: { @@ -12,6 +15,8 @@ vi.mock('electron', () => ({ }, ipcMain: { handle: handleMock, + on: onMock, + removeAllListeners: removeAllListenersMock, removeHandler: removeHandlerMock } })) @@ -19,9 +24,24 @@ vi.mock('electron', () => ({ import { registerRuntimeHandlers } from './runtime' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' +function runtimeCallEvent() { + const mainFrame = {} + return { + sender: { + id: 1, + mainFrame, + on: vi.fn(), + once: vi.fn() + }, + senderFrame: mainFrame + } +} + describe('registerRuntimeHandlers', () => { beforeEach(() => { handleMock.mockReset() + onMock.mockReset() + removeAllListenersMock.mockReset() removeHandlerMock.mockReset() fromWebContentsMock.mockReset() }) @@ -107,7 +127,7 @@ describe('registerRuntimeHandlers', () => { expect(callRegistration).toBeTruthy() const handler = callRegistration![1] - const result = await handler({ sender: {} }, { method: 'status.get' }) + const result = await handler(runtimeCallEvent(), { method: 'status.get' }) expect(result).toMatchObject({ ok: true, @@ -130,7 +150,7 @@ describe('registerRuntimeHandlers', () => { expect(callRegistration).toBeTruthy() const handler = callRegistration![1] - const result = await handler({ sender: {} }, { method: 'projectGroup.list' }) + const result = await handler(runtimeCallEvent(), { method: 'projectGroup.list' }) expect(result).toMatchObject({ ok: true, @@ -139,6 +159,14 @@ describe('registerRuntimeHandlers', () => { }) }) + it('registers local runtime streaming subscription lifecycle handlers', () => { + registerRuntimeHandlers({ syncWindowGraph: vi.fn(), getStatus: vi.fn() } as never) + + expect(handleMock.mock.calls.some(([channel]) => channel === 'runtime:subscribe')).toBe(true) + expect(onMock.mock.calls.some(([channel]) => channel === 'runtime:unsubscribe')).toBe(true) + expect(removeAllListenersMock).toHaveBeenCalledWith('runtime:unsubscribe') + }) + it('deduplicates retries while a terminal fit restore is still pending', async () => { const finishRestoreByPtyId = new Map void>() const reclaimTerminalForDesktop = vi.fn( diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 6e62a4f5ea5..901d14bfce6 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -10,7 +10,10 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' +import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' function boundTerminalFitRestore(pending: Promise): Promise { let timer: ReturnType | undefined @@ -23,9 +26,12 @@ function boundTerminalFitRestore(pending: Promise): Promise { export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { const pendingTerminalFitRestores = new Map>() + const desktopSenders = new DesktopRuntimeSenderLifecycle(runtime) ipcMain.removeHandler('runtime:syncWindowGraph') ipcMain.removeHandler('runtime:getStatus') ipcMain.removeHandler('runtime:call') + ipcMain.removeHandler('runtime:subscribe') + ipcMain.removeAllListeners('runtime:unsubscribe') ipcMain.handle( 'runtime:syncWindowGraph', @@ -53,18 +59,82 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle( 'runtime:call', async ( - _event, + event, args: { method: string; params?: unknown } ): Promise> => { - return (await new RpcDispatcher({ runtime }).dispatch({ - id: 'desktop-ipc', - authToken: 'desktop-ipc', - method: args.method, - params: args.params - })) as RuntimeRpcResponse + if (event.senderFrame !== event.sender.mainFrame) { + throw new Error('Runtime RPC call must originate from the current main frame') + } + return (await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch( + { + id: 'desktop-ipc', + authToken: 'desktop-ipc', + method: args.method, + params: args.params + }, + { + clientId: 'desktop-renderer', + clientKind: 'runtime', + connectionId: desktopSenders.connectionIdFor(event.sender), + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + )) as RuntimeRpcResponse } ) + ipcMain.handle( + 'runtime:subscribe', + ( + event, + args: { subscriptionId: string; method: string; params?: unknown } + ): { subscribed: boolean } => { + if (event.senderFrame !== event.sender.mainFrame) { + throw new Error('Runtime subscription must originate from the current main frame') + } + const senderSubscriptions = desktopSenders.subscriptionsFor(event.sender) + const connectionId = desktopSenders.connectionIdFor(event.sender) + const previous = senderSubscriptions.get(args.subscriptionId) + previous?.abort() + const controller = new AbortController() + senderSubscriptions.set(args.subscriptionId, controller) + const channel = `runtime:subscription:${args.subscriptionId}` + const stop = (): void => { + if (senderSubscriptions.get(args.subscriptionId) === controller) { + senderSubscriptions.delete(args.subscriptionId) + } + } + void new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) + .dispatchStreaming( + { + id: args.subscriptionId, + authToken: 'desktop-ipc', + method: args.method, + params: args.params + }, + (response) => { + if (!controller.signal.aborted && !event.sender.isDestroyed()) { + event.sender.send(channel, JSON.parse(response) as RuntimeRpcResponse) + } + }, + { + signal: controller.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + connectionId, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + .finally(stop) + return { subscribed: true } + } + ) + + ipcMain.on('runtime:unsubscribe', (event, args: { subscriptionId: string }) => { + const senderSubscriptions = desktopSenders.existingSubscriptionsFor(event.sender) + senderSubscriptions?.get(args.subscriptionId)?.abort() + senderSubscriptions?.delete(args.subscriptionId) + }) + ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index c33f9e14ef8..6df6a6b0eb5 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -144,6 +144,13 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('fails closed when the renderer has not selected a repo yet', async () => { + const listed = await handlers['worktrees:list'](null, undefined) + + expect(listed).toEqual([]) + expect(store.getRepo).not.toHaveBeenCalled() + }) + it('returns reconstructed rows when an SSH provider is unavailable', async () => { const repo = { id: 'repo-ssh', diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index f2388b3ce43..ac98130b5eb 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -149,8 +149,13 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) }) - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) + ipcMain.handle('worktrees:list', async (_event, args: { repoId: string } | undefined) => { + // Renderer startup can race repo selection; malformed requests must fail closed, not crash the handler. + const repoId = typeof args?.repoId === 'string' ? args.repoId : '' + if (!repoId) { + return [] + } + const repo = store.getRepo(repoId) if (!repo) { return [] } diff --git a/src/main/native-chat/agent-session-journal/journal-blob-store.ts b/src/main/native-chat/agent-session-journal/journal-blob-store.ts new file mode 100644 index 00000000000..0bd921e1df7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-blob-store.ts @@ -0,0 +1,92 @@ +// Content-addressed store for the remainder of a bounded payload. +// +// Blobs are named by their sha256, so writing the same output twice costs one +// file and re-import is idempotent. They live beside the journal (host-side +// per-workspace state, never inside the user's working tree) and share the +// epoch's retention: compaction prunes every blob no retained row references. + +import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' + +const BLOB_DIR = 'blobs' +const DIGEST_PATTERN = /^[0-9a-f]{64}$/ + +/** A digest arrives back from a row on disk, so it is untrusted by the time it + * reaches the filesystem: anything but a bare sha256 could escape the store. */ +function blobPath(journalDir: string, digest: string): string | null { + return DIGEST_PATTERN.test(digest) ? join(journalDir, BLOB_DIR, digest) : null +} + +/** Persist `payload` under its digest. Returns the digest so the caller can + * stamp it on the row it is about to append. */ +export async function putJournalBlob( + journalDir: string, + digest: string, + payload: string +): Promise { + const target = blobPath(journalDir, digest) + if (!target) { + throw new Error('refusing to write a journal blob under a name that is not a sha256 digest') + } + // Content addressing makes a rewrite pointless: identical digest, identical bytes. + if (await pathExists(target)) { + return digest + } + await mkdir(join(journalDir, BLOB_DIR), { recursive: true }) + await writeFileDurable(durableWriteTempPath(target), target, payload) + return digest +} + +export async function readJournalBlob(journalDir: string, digest: string): Promise { + const source = blobPath(journalDir, digest) + if (!source) { + return null + } + try { + return await readFile(source, 'utf-8') + } catch { + return null + } +} + +/** Remove a blob written speculatively for a row that was rejected. */ +export async function removeJournalBlob(journalDir: string, digest: string): Promise { + const target = blobPath(journalDir, digest) + if (target) { + await rm(target, { force: true }) + } +} + +/** Drop every blob outside `retained`. Called from compaction, under the + * current lease fence, after the snapshot is durable — so a crash mid-prune + * leaves extra blobs rather than dangling references. */ +export async function pruneJournalBlobs( + journalDir: string, + retained: ReadonlySet +): Promise { + let removed = 0 + let names: string[] + try { + names = await readdir(join(journalDir, BLOB_DIR)) + } catch { + return 0 + } + for (const name of names) { + if (retained.has(name)) { + continue + } + await rm(join(journalDir, BLOB_DIR, name), { force: true }).catch(() => {}) + removed += 1 + } + return removed +} + +async function pathExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-compaction.ts b/src/main/native-chat/agent-session-journal/journal-compaction.ts new file mode 100644 index 00000000000..6533ceda5ab --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-compaction.ts @@ -0,0 +1,172 @@ +// Retention and compaction. +// +// The snapshot carries the retained tail with it, so publishing both is ONE +// atomic write and there is no window where the folded state exists without the +// rows a reconnecting client still needs. Truncating the log afterwards is +// idempotent: a crash before it leaves the log a superset of the tail. +// +// The retained tail must cover the longest reconnect window Orca supports, or a +// client that was merely asleep gets a full snapshot reload instead of a resume. + +import { + blobDigestsInBody, + referencedBlobDigests, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { pruneJournalBlobs } from './journal-blob-store' +import { + rewriteJournalLog, + writeJournalSnapshotFile, + type JournalSnapshotFile +} from './journal-log-file' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { JournalRow } from './journal-row-schema' +import { AgentSessionJournalError } from './journal-write-guards' + +export type JournalCompactionPolicy = { + /** Always keep at least this many rows, however old they are. */ + minTailRows: number + /** Keep every row observed within this window. */ + retainTailMs: number + /** + * `window` honours `retainTailMs` outright. `budget-pressure` lets it yield: + * the alternative is refusing the user's writes until the window ages out, + * and the tail is only a resume optimization — compaction folds every shed + * row into the snapshot before truncating the log, so a client that loses + * its resume point reloads instead of losing conversation. Defaults to + * `window`. + */ + retention?: 'window' | 'budget-pressure' +} + +/** Two hours of tail comfortably covers a phone that slept through a commute, + * which is the longest reconnect Orca resumes rather than reloads. */ +export const DEFAULT_JOURNAL_COMPACTION_POLICY: JournalCompactionPolicy = { + minTailRows: 512, + retainTailMs: 2 * 60 * 60 * 1000 +} + +export type JournalCompactionResult = { + tailRows: JournalRow[] + compactedThrough: number + oldestSequence: number +} + +export async function compactJournal(input: { + journalDir: string + state: JournalReducerState + tailRows: readonly JournalRow[] + policy?: JournalCompactionPolicy + now: number + maxSessionBytes: number +}): Promise { + const policy = input.policy ?? DEFAULT_JOURNAL_COMPACTION_POLICY + const retained = retainTail(input.tailRows, policy, input.now) + const rendered = renderJournalState(input.state) + const compactedThrough = input.state.lastSequence + + const snapshot: JournalSnapshotFile = { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: input.state.epoch, + compactedThrough, + highestFence: input.state.highestFence, + items: rendered.items, + submissions: rendered.submissions, + receipts: [...input.state.receipts.values()].map((receipt) => ({ + clientMessageId: receipt.clientMessageId, + providerItemId: receipt.providerItemId, + epoch: receipt.cursor.epoch, + sequence: receipt.cursor.sequence, + acceptedAt: receipt.acceptedAt + })), + aliases: [...input.state.aliases.entries()].map(([providerItemId, itemId]) => ({ + providerItemId, + itemId + })), + tombstones: [...input.state.tombstones.entries()].map(([itemId, revision]) => ({ + itemId, + revision + })), + tail: retained + } + + const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8') + if (snapshotBytes > input.maxSessionBytes) { + throw new AgentSessionJournalError( + 'journal_bound_exceeded', + `agent-session journal snapshot reached its ${input.maxSessionBytes}-byte bound` + ) + } + + await writeJournalSnapshotFile(input.journalDir, snapshot) + await rewriteJournalLog(input.journalDir, retained) + // Blobs are pruned last: a crash before this leaks bytes, whereas pruning + // first would strand a snapshot pointing at a payload that no longer exists. + const retainedDigests = referencedBlobDigests(input.state) + for (const row of retained) { + if (row.kind === 'item') { + blobDigestsInBody(row.body, retainedDigests) + } + } + await pruneJournalBlobs(input.journalDir, retainedDigests) + + return { + tailRows: retained, + compactedThrough, + oldestSequence: retained[0]?.seq ?? compactedThrough + 1 + } +} + +function retainTail( + rows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): JournalRow[] { + if (rows.length <= policy.minTailRows) { + return [...rows] + } + const floor = now - policy.retainTailMs + const byAge = rows.findIndex((row) => row.ts >= floor) + const byCount = rows.length - policy.minTailRows + const start = byAge === -1 ? byCount : Math.min(byAge, byCount) + if (policy.retention !== 'budget-pressure') { + return rows.slice(start) + } + // Halve rather than empty: the newer half keeps live clients resuming, and + // shedding at least one row guarantees the append that triggered this makes + // progress instead of latching the session read-only. + return rows.slice(Math.max(start, Math.ceil(rows.length / 2))) +} + +/** Only when the retention window would actually drop rows: inside it, + * compaction rewrites an identical log, and doing that per append is a full + * state serialization on the hot path. */ +export function journalTailIsReadyToCompact( + tailRows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): boolean { + if (tailRows.length <= policy.minTailRows * 2) { + return false + } + return (tailRows[0]?.ts ?? now) < now - policy.retainTailMs +} + +/** The policy an append falls back to when the size bound would otherwise + * refuse it: both floors that normally protect the tail step aside. */ +export function budgetPressurePolicy(policy: JournalCompactionPolicy): JournalCompactionPolicy { + return { ...policy, minTailRows: 0, retention: 'budget-pressure' } +} + +/** Budget pressure may need to shed rows before the ordinary batching threshold. + * Pass a `budget-pressure` policy, or a tail wholly inside the retention + * window answers false and the size bound refuses every append until it ages + * out — two hours of a session the user cannot write to. */ +export function journalTailCanShedRows( + tailRows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): boolean { + return retainTail(tailRows, policy, now).length < tailRows.length +} diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts b/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts new file mode 100644 index 00000000000..429881e274a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts @@ -0,0 +1,72 @@ +// Corruption never deletes history. A journal that cannot be read end to end +// keeps its intact prefix live and moves the unreadable remainder aside, so the +// bytes stay on disk for inspection instead of being rebuilt into an empty epoch. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + JOURNAL_SNAPSHOT_FILE, + quarantineJournalRemainder, + readJournalLog, + rewriteJournalLog +} from './journal-log-file' +import type { JournalRow } from './journal-row-schema' + +/** Keep the readable prefix and set the unreadable suffix aside. */ +export async function quarantineCorruptSuffix( + journalDir: string, + retainedRows: readonly JournalRow[], + remainder: string | undefined +): Promise { + if (remainder) { + await quarantineJournalRemainder(journalDir, remainder) + } + await rewriteJournalLog(journalDir, retainedRows) +} + +/** Copy everything aside before a read-only journal is rebuilt under a newer + * schema: those rows are unreadable to THIS build, not worthless. The + * snapshot is preserved as raw bytes — a future-version snapshot does not + * parse under this build's schema, and its bytes must survive verbatim. */ +export async function quarantineUnreadableSchema(journalDir: string): Promise { + const snapshot = await readSnapshotBytes(journalDir) + const log = await readJournalLog(journalDir) + const preserved = [ + snapshot ?? '', + log.rows.map((row) => JSON.stringify(row)).join('\n'), + log.remainder ?? '' + ] + .filter(Boolean) + .join('\n') + if (preserved) { + await quarantineJournalRemainder(journalDir, preserved) + } +} + +async function readSnapshotBytes(journalDir: string): Promise { + try { + return await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} + +/** The disclosure row for lines that failed to parse. Skipped lines are lost + * rows; counting them silently is the drop this exists to prevent. */ +export function malformedRowsDisclosure(count: number): { + identity: { provider: 'orca'; clientMessageId: string } + body: { kind: 'status'; text: string } +} { + const plural = count === 1 ? '' : 's' + return { + // One stable identity, so a reopen upserts the same row instead of adding one. + identity: { provider: 'orca', clientMessageId: 'journal-malformed-lines' }, + body: { + kind: 'status', + text: `${count} journal line${plural} could not be read and ${count === 1 ? 'was' : 'were'} skipped` + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts new file mode 100644 index 00000000000..0f8d85d34fc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -0,0 +1,382 @@ +// The crash boundary: the host wrote a submission row, dispatched, and died +// before it learned whether the provider took the message. Replay must reconcile +// without duplicating the user's message and without losing it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { digestPayload } from './journal-payload-bounds' +import { + reconcileSubmissions, + type ProviderHistoryItem, + type ProviderHistoryWindow +} from './journal-submission-reconciler' +import { openAgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' + +const ACCEPTED_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN_ID, + ordinal: 0 +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +async function open() { + return openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) +} + +/** A Codex `userMessage` history item; `clientId` is the echoed client message id. */ +function history(input: { + itemId: string + clientId: string | null + text: string + ordinal: number +}): ProviderHistoryItem { + return { + providerItemId: input.itemId, + clientMessageId: input.clientId, + payloadFingerprint: digestPayload(input.text), + identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } + } +} + +function window( + items: ProviderHistoryItem[], + overrides: Partial = {} +): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('crash between provider accept and journal commit', () => { + it('reconciles the echo into the existing bubble instead of duplicating it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + // Host dies here: the provider accepted, but no dispatch row was written. + + const restarted = await open() + expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + + if (outcome?.outcome !== 'accepted') { + throw new Error('expected the echoed submission to reconcile as accepted') + } + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: outcome.identity, + fence: 2, + recovered: true + }) + // The provider's own copy of the message arrives next, under the identity + // reconciliation adopted. It must land in the bubble the user already sees. + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + + const items = restarted.snapshot().items + expect(items).toHaveLength(1) + expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) + expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity)) + }) + + it('reports a rejected submission as never delivered, and never re-sends it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('never landed'), + body: userMessage('never landed'), + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([]) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'rejected', + reason: 'not_delivered' + }) + + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + reason: 'not_delivered', + fence: 2, + recovered: true + }) + // The bubble survives with an explicit terminal state — the message is not + // silently retried and not silently dropped. + expect(restarted.snapshot().items).toHaveLength(1) + expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') + expect(restarted.receiptFor('cm_1')).toBeNull() + }) + + it('survives replay of an already-reconciled journal without changing the answer', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('once'), + body: userMessage('once'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + const settled = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(settled) + expect(reopened.pendingSubmissions()).toHaveLength(0) + expect( + reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) + ).toEqual([]) + }) + + it('keeps the receipt after the row that minted it was compacted away', async () => { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + compaction: { minTailRows: 1, retainTailMs: 0 } + }) + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('kept'), + body: userMessage('kept'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + await journal.compact() + + const reopened = await open() + expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY)) + }) +}) + +describe('reconciliation matching', () => { + const submissions = [ + { + clientMessageId: 'cm_1', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + }, + { + clientMessageId: 'cm_2', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 2, + resolvedAt: null + } + ] + + it('matches each submission to its own echo when the provider carries client ids', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) + ]) + }) + + it('refuses to guess between two identical payloads with no id to tell them apart', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) + expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) + }) + + it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + }) + + it('never matches on text alone when the fingerprint disagrees', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + { + providerItemId: 'item-1', + clientMessageId: null, + payloadFingerprint: digestPayload('different payload, same rendered text'), + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } + } + ]) + }) + expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) + }) + + it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) + ]) + }) + + it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { + const [outcome] = reconcileSubmissions({ + submissions: [{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }], + history: window([ + // The provider renumbered its raw id; the identity-derived key did not move. + history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) + }) + + it('never hands one provider item to two submissions', () => { + const outcomes = reconcileSubmissions({ + submissions: [ + { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, + submissions[1]! + ], + // One item, wanted by both passes: cm_1 adopted its key, cm_2 is echoed on + // it. Adopting it twice would render the same provider message twice. + history: window([ + history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), + expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) + ]) + }) + + it('stays unknown when the history boundary cannot be trusted', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { boundaryConsistent: false }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'history_boundary_inconsistent' + }) + }) + + it('stays unknown while a turn is still running', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { turnInFlight: true }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'turn_in_flight' + }) + }) + + it('leaves settled submissions alone', () => { + expect( + reconcileSubmissions({ + submissions: [ + { + ...submissions[0]!, + dispatchState: 'accepted', + providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) + }, + { ...submissions[1]!, dispatchState: 'rejected' } + ], + history: window([]) + }) + ).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.test.ts b/src/main/native-chat/agent-session-journal/journal-cursor.test.ts new file mode 100644 index 00000000000..704973e7ebd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + findSequenceGap, + resolveJournalResume, + sameJournalCursor, + type JournalCursorRange +} from './journal-cursor' + +const RANGE: JournalCursorRange = { epoch: 'e1', lastSequence: 40, oldestSequence: 11 } + +describe('resolveJournalResume', () => { + it('resumes from a cursor inside the retained tail', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 25 })).toEqual({ + ok: true, + afterSequence: 25 + }) + }) + + it('resumes from a cursor sitting exactly on the compaction boundary', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 10 })).toEqual({ + ok: true, + afterSequence: 10 + }) + }) + + it('resumes from a cursor at the tip with nothing to send', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 40 })).toEqual({ + ok: true, + afterSequence: 40 + }) + }) + + it('forces a reload when the epoch rolled', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e0', sequence: 25 })).toEqual({ + ok: false, + reset: 'epoch_changed' + }) + }) + + it('forces a reload when the epoch rolled even at a sequence this epoch also holds', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e0', sequence: 40 }).ok).toBe(false) + }) + + it('forces a reload when the client is ahead of the journal', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 41 })).toEqual({ + ok: false, + reset: 'cursor_ahead' + }) + }) + + it('forces a reload when the cursor fell below the compaction floor', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 9 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + }) + + it('resumes a fresh client from sequence 0 on an uncompacted journal', () => { + const fresh: JournalCursorRange = { epoch: 'e1', lastSequence: 3, oldestSequence: 1 } + expect(resolveJournalResume(fresh, { epoch: 'e1', sequence: 0 })).toEqual({ + ok: true, + afterSequence: 0 + }) + }) + + it('rejects sequence 0 once the journal has compacted past it', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 0 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + }) +}) + +describe('sameJournalCursor', () => { + it('requires both the epoch and the sequence to match', () => { + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e1', sequence: 3 })).toBe(true) + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e2', sequence: 3 })).toBe( + false + ) + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e1', sequence: 4 })).toBe( + false + ) + }) +}) + +describe('findSequenceGap', () => { + it('accepts a contiguous run', () => { + expect(findSequenceGap([7, 8, 9], 7)).toBeNull() + }) + + it('accepts an empty run', () => { + expect(findSequenceGap([], 7)).toBeNull() + }) + + it('reports the first missing sequence', () => { + expect(findSequenceGap([7, 8, 10], 7)).toEqual({ gapAt: 9 }) + }) + + it('reports a run that starts above the expected first sequence', () => { + expect(findSequenceGap([8, 9], 7)).toEqual({ gapAt: 7 }) + }) + + it('reports a reused sequence', () => { + expect(findSequenceGap([7, 7, 8], 7)).toEqual({ gapAt: 8 }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.ts b/src/main/native-chat/agent-session-journal/journal-cursor.ts new file mode 100644 index 00000000000..bdec8adfd19 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor.ts @@ -0,0 +1,98 @@ +// Epoch-qualified cursor resume. +// +// A cursor is only meaningful inside its epoch: rollover invalidates every one +// of them, and the client takes a clean snapshot reload rather than being +// handed rows that belong to a rebuilt timeline. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import type { JournalReadSince } from './journal-store-contracts' +import type { JournalRow } from './journal-row-schema' + +export type JournalCursorRange = { + epoch: string + /** Sequence of the newest appended row; 0 when the epoch is empty. */ + lastSequence: number + /** Lowest sequence still individually replayable after compaction. */ + oldestSequence: number +} + +export type JournalResume = + /** Replay rows with `seq > afterSequence`. */ + { ok: true; afterSequence: number } | { ok: false; reset: AgentJournalResetReason } + +/** Total order over cursors within one epoch; cross-epoch comparison is + * meaningless, so callers must check the epoch first. */ +export function sameJournalCursor(a: AgentJournalCursor, b: AgentJournalCursor): boolean { + return a.epoch === b.epoch && a.sequence === b.sequence +} + +/** + * Decide whether a reconnecting client can resume from `cursor`. + * + * An epoch mismatch, a cursor ahead of the journal (the client saw rows this + * host no longer has — a rolled-back or rebuilt prefix), and a cursor below the + * compaction floor all resolve to a snapshot reload. None of them is recoverable + * by shipping a partial batch. + */ +export function resolveJournalResume( + range: JournalCursorRange, + cursor: AgentJournalCursor +): JournalResume { + if (cursor.epoch !== range.epoch) { + return { ok: false, reset: 'epoch_changed' } + } + if (cursor.sequence > range.lastSequence) { + return { ok: false, reset: 'cursor_ahead' } + } + // `oldestSequence - 1` is the compaction boundary: a client sitting exactly on + // it has seen everything folded into the snapshot and can take the tail. + if (cursor.sequence < range.oldestSequence - 1) { + return { ok: false, reset: 'cursor_compacted' } + } + return { ok: true, afterSequence: cursor.sequence } +} + +/** Contiguity check over a replayed row sequence. A gap means the journal lost + * a row; the reader must treat it as corrupt and force epoch rollover rather + * than render a partial timeline. */ +export function findSequenceGap( + sequences: readonly number[], + expectedFirst: number +): { gapAt: number } | null { + let expected = expectedFirst + for (const sequence of sequences) { + if (sequence !== expected) { + return { gapAt: expected } + } + expected += 1 + } + return null +} + +/** Rows appended after `cursor`, or the reset a client must take instead. A + * read-only journal always resets: this build cannot vouch for what it holds. */ +export function readJournalSince( + source: { + state: { epoch: string; lastSequence: number; oldestSequence: number } + tailRows: readonly JournalRow[] + readOnly: boolean + }, + cursor: AgentJournalCursor, + currentCursor: () => AgentJournalCursor +): JournalReadSince { + if (source.readOnly) { + return { ok: false, reset: 'schema_unreadable' } + } + const resume = resolveJournalResume(source.state, cursor) + if (!resume.ok) { + return { ok: false, reset: resume.reset } + } + return { + ok: true, + rows: source.tailRows.filter((row) => row.seq > resume.afterSequence), + cursor: currentCursor() + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts new file mode 100644 index 00000000000..8cc12c5ee66 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { copyFileDurable } from '../../durable-file-write' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { compactJournal, type JournalCompactionPolicy } from './journal-compaction' +import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE, appendJournalRows } from './journal-log-file' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { journalRowByteLength } from './journal-row-schema' +import { assertJournalFence, type JournalAppendBudget } from './journal-write-guards' +import type { JournalLoad } from './journal-open' + +export type JournalReplacementItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt?: number +} + +export async function replaceJournalEpoch(input: { + journalDir: string + identity: AgentSessionJournalIdentity + reason: AgentJournalEpochReason + fence: number + items: readonly JournalReplacementItem[] + budget: JournalAppendBudget + compaction: JournalCompactionPolicy + now: () => number + mintEpoch: () => string + onSnapshotPublished: (loaded: JournalLoad) => void +}): Promise { + const stagingDir = await mkdtemp(join(input.journalDir, '.epoch-replacement-')) + try { + const epoch = input.mintEpoch() + const state = createJournalReducerState(input.identity.sessionId, epoch) + const epochRow: JournalRow = { + kind: 'epoch', + reason: input.reason, + providerHandle: input.identity.providerHandle, + ...journalRowBase(epoch, 1, input.fence, input.now()) + } + const rows: JournalRow[] = [epochRow] + applyJournalRow(state, epochRow) + let sizeBytes = journalRowByteLength(epochRow) + await appendJournalRows(stagingDir, [epochRow]) + + for (const item of input.items) { + const appendTime = input.now() + const row = buildJournalItemRow({ + state, + identity: item.identity, + body: item.body, + seq: state.lastSequence + 1, + fence: input.fence, + ts: item.observedAt ?? appendTime + }) + assertJournalFence(row.fence, state.highestFence) + input.budget.assert(row, appendTime, sizeBytes) + await appendJournalRows(stagingDir, [row]) + applyJournalRow(state, row) + rows.push(row) + sizeBytes += journalRowByteLength(row) + } + + const compacted = await compactJournal({ + journalDir: stagingDir, + state, + tailRows: rows, + policy: input.compaction, + now: input.now(), + maxSessionBytes: input.budget.maxSessionBytes + }) + await publishPreparedFile(stagingDir, input.journalDir, JOURNAL_SNAPSHOT_FILE) + state.oldestSequence = compacted.oldestSequence + input.onSnapshotPublished({ + state, + tailRows: compacted.tailRows, + compactedThrough: compacted.compactedThrough, + readOnly: false, + corrupt: false, + malformedRows: 0, + sizeBytes: compacted.tailRows.reduce((total, row) => total + journalRowByteLength(row), 0) + }) + await publishPreparedFile(stagingDir, input.journalDir, JOURNAL_LOG_FILE) + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } +} + +async function publishPreparedFile( + stagingDir: string, + journalDir: string, + fileName: string +): Promise { + const copied = await copyFileDurable(join(stagingDir, fileName), join(journalDir, fileName)) + if (!copied) { + throw new Error(`prepared journal file disappeared before publish: ${fileName}`) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts new file mode 100644 index 00000000000..0cf3f9d6cda --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -0,0 +1,56 @@ +// Opening a new epoch. +// +// The snapshot is what names the live epoch, so it is published BEFORE the log +// is reset. A crash mid-rollover therefore leaves stale-epoch rows behind the +// new snapshot, which `loadJournal` drops — the reverse order would leave a +// journal whose log no longer matches any epoch anyone can name. + +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' +import { compactJournal } from './journal-compaction' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { journalRowByteLength } from './journal-row-schema' +import type { JournalLoad } from './journal-open' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' + +export async function publishNewEpoch(input: { + journalDir: string + sessionId: string + providerHandle: AgentSessionProviderHandle + epoch: string + reason: AgentJournalEpochReason + fence: number + now: number +}): Promise { + const row: JournalRow = { + kind: 'epoch', + reason: input.reason, + providerHandle: input.providerHandle, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: input.epoch, + seq: 1, + fence: input.fence, + ts: input.now + } + const state = createJournalReducerState(input.sessionId, input.epoch) + await compactJournal({ + journalDir: input.journalDir, + state, + tailRows: [row], + policy: { minTailRows: 1, retainTailMs: Number.POSITIVE_INFINITY }, + now: input.now, + maxSessionBytes: DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes + }) + applyJournalRow(state, row) + state.oldestSequence = 1 + return { + state, + tailRows: [row], + compactedThrough: 0, + readOnly: false, + corrupt: false, + malformedRows: 0, + sizeBytes: journalRowByteLength(row) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts new file mode 100644 index 00000000000..0e05fc3fc93 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' + +// Fixtures mirror the shapes the providers actually emit: a resumed Codex +// thread renumbers its items positionally, and a forked Claude session copies +// history with the ORIGINAL item uuids. + +const THREAD = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' +const TURN_A = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' +const TURN_B = '019fd8cb-1c40-7a02-9f31-0f1a54b7c211' + +describe('codex identity survives positional renumbering', () => { + it('keys the same logical item identically before and after a resume', () => { + // First run: the app server labels the second turn's user message item-3. + const live: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: TURN_B, + ordinal: 0 + } + // After `thread/resume` the same item comes back as item-1 of the replayed + // history. Ordinal-within-turn is unchanged, so the key is unchanged. + const resumed: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: TURN_B, + ordinal: 0 + } + expect(agentJournalItemKey(resumed)).toBe(agentJournalItemKey(live)) + }) + + it('separates two items inside one turn', () => { + const first = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 0 + }) + const second = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 1 + }) + expect(first).not.toBe(second) + }) + + it('disambiguates a fork that copies turns keeping their original turn ids', () => { + const original = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 0 + }) + const forked = agentJournalItemKey({ + provider: 'codex', + threadId: '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70', + turnId: TURN_A, + ordinal: 0 + }) + expect(forked).not.toBe(original) + }) +}) + +describe('claude identity', () => { + it('keys on (session id, uuid)', () => { + const key = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + expect(key).toBe( + 'claude:29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88:c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + ) + }) + + it('reconciles a forked transcript onto the parent item rather than duplicating it', () => { + // `--fork-session` mints a new session id but copies records verbatim, so a + // copied record still names the session it was written in. + const parent = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + const copiedIntoFork = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + expect(copiedIntoFork).toBe(parent) + }) + + it('keeps a genuinely new item in the fork distinct', () => { + const parent = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + const minted = agentJournalItemKey({ + provider: 'claude', + sessionId: '7b1e5d33-0f28-42ac-8d59-9a4c6e2b1f70', + uuid: 'f8b2c9a1-3e77-4c60-b1a2-5d0e7f4a9c33' + }) + expect(minted).not.toBe(parent) + }) +}) + +describe('key encoding', () => { + it('cannot be collided by a separator inside an id', () => { + const a = agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'a:b', + recordId: 'c' + }) + const b = agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'a', + recordId: 'b:c' + }) + expect(a).not.toBe(b) + }) + + it('separates the provider namespaces', () => { + const orca = agentJournalItemKey({ provider: 'orca', clientMessageId: 'x' }) + const legacy = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'x', + recordId: 'x' + }) + expect(orca).not.toBe(legacy) + }) + + it('derives the submission slot from the same function the reducer uses', () => { + expect(agentJournalSubmissionKey('cm_42')).toBe( + agentJournalItemKey({ provider: 'orca', clientMessageId: 'cm_42' }) + ) + }) +}) + +describe('bounded component domain separation', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: THREAD, turnId, ordinal: 0 }) + + it('separates an oversized component from the raw string matching its digest form', () => { + const oversizedKey = keyFor(oversizedTurnId) + expect(oversizedKey).toBe(`codex:${THREAD}:${digestFormMimic}:0`) + expect(oversizedKey).not.toBe(keyFor(digestFormMimic)) + }) + + it('keeps both persisted key spellings stable through parse and re-key', () => { + for (const turnId of [oversizedTurnId, digestFormMimic]) { + const key = keyFor(turnId) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + } + expect(parseAgentJournalItemKey(keyFor(digestFormMimic))).toEqual({ + provider: 'codex', + threadId: THREAD, + turnId: digestFormMimic, + ordinal: 0 + }) + }) +}) + +describe('oversized identity bounding on Unicode boundaries', () => { + // 39 UTF-16 units of ASCII put the astral character's surrogate pair across + // the 40-unit diagnostic-head cut. Pre-fix the head ended in a lone high + // surrogate and `encodeURIComponent` threw `URIError: URI malformed`. + const STRADDLING = `${'a'.repeat(39)}😀${'x'.repeat(1100)}` + const straddlingIdentity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: STRADDLING, + ordinal: 0 + } + + it('keys a valid astral id whose character straddles the head cut', () => { + expect(() => agentJournalItemKey(straddlingIdentity)).not.toThrow() + expect(boundJournalKeyComponent(STRADDLING).length).toBeLessThan( + MAX_JOURNAL_KEY_COMPONENT_CHARS + ) + }) + + it('stays deterministic and collision-resistant for straddling ids', () => { + expect(agentJournalItemKey(straddlingIdentity)).toBe(agentJournalItemKey(straddlingIdentity)) + // A different oversized value sharing the same head still gets its own key. + expect(agentJournalItemKey({ ...straddlingIdentity, turnId: `${STRADDLING}y` })).not.toBe( + agentJournalItemKey(straddlingIdentity) + ) + }) + + it('re-deriving from the parsed bounded key is a fixed point', () => { + const key = agentJournalItemKey(straddlingIdentity) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + }) + + it('keeps an astral character that lands entirely inside the head', () => { + const inside = `${'a'.repeat(38)}😀${'x'.repeat(1100)}` + const bounded = boundJournalKeyComponent(inside) + expect(bounded.startsWith(`${'a'.repeat(38)}😀~orca-oversized~`)).toBe(true) + expect(() => encodeURIComponent(bounded)).not.toThrow() + }) + + it('drops only the split surrogate from the straddling head', () => { + const bounded = boundJournalKeyComponent(STRADDLING) + expect(bounded.startsWith(`${'a'.repeat(39)}~orca-oversized~`)).toBe(true) + expect(() => encodeURIComponent(bounded)).not.toThrow() + }) +}) + +describe('ill-formed UTF-16 identity totality', () => { + // JSON.parse admits lone surrogates, so any JSON string is a legal component; + // pre-fix these threw `URIError: URI malformed` in `encodeURIComponent`. + const LONE_HIGH = '\ud83d' + const LONE_LOW = '\ude00' + + it('keys a lone-high-surrogate id without throwing, deterministically', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: LONE_HIGH, + uuid: 'u-1' + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + expect(agentJournalItemKey(identity)).toBe(agentJournalItemKey(identity)) + }) + + it('keys an oversized id carrying a lone low surrogate inside the head', () => { + const identity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: `${LONE_LOW}${'x'.repeat(1100)}`, + ordinal: 0 + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + }) + + it('keys an oversized id with a lone high surrogate away from the head cut', () => { + const identity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: `${'a'.repeat(10)}${LONE_HIGH}${'b'.repeat(1100)}`, + ordinal: 0 + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + }) + + it('cannot collide an ill-formed id with its replacement-character spelling', () => { + const keyFor = (sessionId: string) => + agentJournalItemKey({ provider: 'claude', sessionId, uuid: 'u-1' }) + expect(keyFor(LONE_HIGH)).not.toBe(keyFor('�')) + expect(keyFor(LONE_HIGH)).not.toBe(keyFor(LONE_LOW)) + const oversized = (head: string) => `${head}${'x'.repeat(1100)}` + expect(keyFor(oversized(LONE_HIGH))).not.toBe(keyFor(oversized('�'))) + }) + + it('re-deriving from a parsed ill-formed key is a fixed point', () => { + const key = agentJournalItemKey({ provider: 'claude', sessionId: LONE_HIGH, uuid: 'u-1' }) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + }) + + it('leaves well-formed short components verbatim', () => { + expect(boundJournalKeyComponent('turn-1')).toBe('turn-1') + expect(boundJournalKeyComponent('😀 café')).toBe('😀 café') + }) +}) + +describe('malformed persisted keys decode to null instead of throwing', () => { + it('returns null for malformed percent sequences', () => { + for (const key of ['%', 'claude:%E0%A4%A:u-1', 'codex:a:b:1%ZZ', 'claude:%ED%A0%BD:u-1']) { + expect(parseAgentJournalItemKey(key)).toBeNull() + } + }) + + it('still round-trips well-formed keys containing the delimiter and spaces', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 's:1', + uuid: 'u 1' + } + expect(parseAgentJournalItemKey(agentJournalItemKey(identity))).toEqual(identity) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts new file mode 100644 index 00000000000..c7903e604d3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts @@ -0,0 +1,87 @@ +// Identity anchors for bridge-era transcript lines. +// +// The transcript decoders return a render model, not an identity, so the import +// reads identity from the SAME raw line the decoder consumed rather than +// inferring it from decoded text. +// +// Claude gets its real identity namespace: the project jsonl IS the provider's +// store, and `uuid` survives `--fork-session` unchanged, so a later structured +// session reconciles against these keys directly. +// +// Codex, Grok, and omp get the `legacy` namespace. A Codex rollout file records +// `response_item` ids (`msg_…`, `rs_…`, `ctc_…`) which are a different namespace +// from the app-server's positional `item-N` ordinals, and rollout records carry +// no turn id at all — so a rollout line cannot be expressed as a stable +// `(threadId, turnId, ordinal)` key without guessing. Legacy items are therefore +// import-scoped, and a later structured resume rolls the epoch and rebuilds. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { NativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' + +export type LegacyIdentityTracker = { + /** Identity for whatever the decoder emits from this raw line. Called for + * every line in file order, including ones the decoder discards. */ + identify(line: string, lineIndex: number): AgentJournalItemIdentity +} + +export function createLegacyIdentityTracker(input: { + transcriptAgent: NativeChatTranscriptAgent + agent: AgentType + sessionId: string +}): LegacyIdentityTracker { + if (input.transcriptAgent === 'claude') { + return { identify: (line, index) => claudeIdentity(line, index, input.agent, input.sessionId) } + } + return { + identify: (line, index) => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: legacyRecordId(line, index) + }) + } +} + +function claudeIdentity( + line: string, + lineIndex: number, + agent: AgentType, + sessionId: string +): AgentJournalItemIdentity { + const record = parseRecord(line) + const uuid = stringField(record, 'uuid') + if (!uuid) { + return { provider: 'legacy', agent, sessionId, recordId: `#${lineIndex}` } + } + // The record's own session id wins: a forked transcript keeps the original + // item uuids, and pairing them with the fork's id would mint new identities. + return { provider: 'claude', sessionId: stringField(record, 'sessionId') ?? sessionId, uuid } +} + +/** `payload.id` when the record carries one, else the line's position. Position + * is deterministic for a given import of a given file, which is all the legacy + * namespace promises. */ +function legacyRecordId(line: string, lineIndex: number): string { + const record = parseRecord(line) + const payload = record?.payload + const id = stringField(payload, 'id') ?? stringField(record, 'id') ?? stringField(record, 'uuid') + return id ?? `#${lineIndex}` +} + +function parseRecord(line: string): Record | null { + try { + const parsed: unknown = JSON.parse(line) + return parsed && typeof parsed === 'object' ? (parsed as Record) : null + } catch { + return null + } +} + +function stringField(source: unknown, key: string): string | null { + if (!source || typeof source !== 'object') { + return null + } + const value = (source as Record)[key] + return typeof value === 'string' && value ? value : null +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts new file mode 100644 index 00000000000..dba8bcddd28 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -0,0 +1,507 @@ +// Legacy import runs the existing per-agent transcript decoders and keys the +// results by identity read off the same raw lines. Fixtures are shaped like the +// files the providers actually write. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { readJournalBlob } from './journal-blob-store' +import { createLegacyIdentityTracker } from './journal-legacy-identity' +import { + appendLegacyTranscriptMessages, + importLegacyTranscriptIntoJournal +} from './journal-legacy-import' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { openAgentSessionJournal, type AgentSessionJournal } from './journal-store' + +const CLAUDE_SESSION = '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88' +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function identity(agent: 'claude' | 'codex', sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent, + providerHandle: + agent === 'claude' + ? { kind: 'claude', sessionId, leafUuid: null } + : { kind: 'codex', threadId: sessionId } + } +} + +async function open( + agent: 'claude' | 'codex', + sessionId: string, + overrides: Partial[0]> = {} +): Promise { + return openAgentSessionJournal({ + identity: identity(agent, sessionId), + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function legacyKey(recordId: string): string { + return agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: CODEX_SESSION, + recordId + }) +} + +async function writeFixture(name: string, lines: unknown[]): Promise { + const path = join(root, name) + await writeFile(path, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') + return path +} + +const CLAUDE_LINES = [ + { type: 'file-history-snapshot', messageId: 'boot', snapshot: {} }, + { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'add a retry' }] }, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04', + timestamp: '2026-08-05T10:00:00.000Z', + cwd: '/Users/dev/project', + sessionId: CLAUDE_SESSION, + version: '2.1.220', + gitBranch: 'main' + }, + { + parentUuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04', + isSidechain: false, + type: 'assistant', + requestId: 'req_01', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'On it.' }], + id: 'msg_ignored_in_favour_of_uuid' + }, + uuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11', + timestamp: '2026-08-05T10:00:04.000Z', + sessionId: CLAUDE_SESSION, + version: '2.1.220' + }, + { + parentUuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11', + isSidechain: false, + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: 'a.ts' } }] + }, + uuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20', + timestamp: '2026-08-05T10:00:07.000Z', + sessionId: CLAUDE_SESSION + }, + { + parentUuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20', + isSidechain: false, + isMeta: true, + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'edited 1 file' }] + }, + uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31', + timestamp: '2026-08-05T10:00:08.000Z', + sessionId: CLAUDE_SESSION + }, + { type: 'last-prompt', leafUuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31' } +] + +// Shapes taken from a real rollout file: `event_msg` records carry no id, and +// `response_item` records do — which is exactly the split the tracker handles. +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:01.000Z', + payload: { type: 'task_started', turn_id: '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'response_item', + timestamp: '2026-08-05T10:00:04.000Z', + payload: { + type: 'reasoning', + id: 'rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0', + summary: [{ type: 'summary_text', text: 'Checking the retry policy.' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:06.000Z', + payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 12 } } } + } +] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-import-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('claude import', () => { + it('keys items by (session id, uuid) from the raw record', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + + expect(result.ok).toBe(true) + expect(journal.snapshot().items.map((entry) => entry.itemId)).toEqual([ + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31' + }) + ]) + }) + + it('stays aligned when the decoder drops lines the tracker still walks', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + const items = journal.snapshot().items + // The first record is a file-history snapshot the decoder discards; if the + // anchors were misaligned, the first bubble would carry its identity. + expect(items[0]?.body).toEqual({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'add a retry' }] + }) + expect(items[2]?.body).toMatchObject({ kind: 'tool-call', name: 'Edit' }) + }) + + it('is idempotent: a second import reproduces the same timeline in a new epoch', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + const options = { filePath } + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options + }) + const first = journal.snapshot() + const firstEpoch = journal.epoch + + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options + }) + const second = journal.snapshot() + + expect(journal.epoch).not.toBe(firstEpoch) + expect(second.items.map((entry) => entry.itemId)).toEqual( + first.items.map((entry) => entry.itemId) + ) + expect(second.items.map((entry) => entry.body)).toEqual(first.items.map((entry) => entry.body)) + }) + + it('reconciles a forked transcript onto the parent uuids rather than duplicating them', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'claude', + agent: 'claude', + // The fork's own session id, which is NOT what the copied records carry. + sessionId: '7b1e5d33-0f28-42ac-8d59-9a4c6e2b1f70' + }) + const copied = JSON.stringify(CLAUDE_LINES[1]) + expect(tracker.identify(copied, 0)).toEqual({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + }) +}) + +describe('codex import', () => { + it('upserts live transcript messages without rolling the structured epoch', async () => { + const journal = await open('codex', CODEX_SESSION) + const epoch = journal.epoch + const message = { + id: 'live-tui-message', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'first version' }], + timestamp: 1_800_000_000_000, + source: 'transcript' as const + } + + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 2, + messages: [message] + }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 2, + messages: [{ ...message, blocks: [{ type: 'text', text: 'final version' }] }] + }) + + expect(journal.epoch).toBe(epoch) + expect(journal.snapshot().items).toMatchObject([ + { + itemId: legacyKey('live-tui-message'), + revision: 2, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'final version' }] + } + } + ]) + }) + + it('keys rollout records in the import-scoped namespace, not as app-server ordinals', async () => { + const filePath = await writeFixture('rollout.jsonl', CODEX_LINES) + const journal = await open('codex', CODEX_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + options: { filePath } + }) + + // A rollout record with its own id keeps it; an `event_msg` has none, so it + // falls back to its line position — deterministic for a given file. + expect(journal.snapshot().items.map((entry) => entry.itemId)).toEqual([ + legacyKey('#2'), + legacyKey('rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0'), + legacyKey('#4') + ]) + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Checking the retry policy.' }] + }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'On it.' }] } + ]) + }) + + it('survives a resumed rollout that renumbers positional item ids', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: CODEX_SESSION + }) + const original = tracker.identify(JSON.stringify(CODEX_LINES[3]), 4) + // Same record replayed at a different position in a resumed file. + const replayed = tracker.identify(JSON.stringify(CODEX_LINES[3]), 11) + expect(replayed).toEqual(original) + expect(original).toMatchObject({ + recordId: 'rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0' + }) + }) + + it('falls back to line position only when a record carries no id', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: CODEX_SESSION + }) + expect(tracker.identify(JSON.stringify({ type: 'event_msg', payload: {} }), 3)).toEqual({ + provider: 'legacy', + agent: 'codex', + sessionId: CODEX_SESSION, + recordId: '#3' + }) + }) +}) + +describe('payload bounds on import', () => { + it('marks a clipped tool result and parks the remainder in the blob store', async () => { + const output = 'y'.repeat(64 * 1024) + const filePath = await writeFixture('claude-big.jsonl', [ + { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_9', content: output }] + }, + uuid: 'aa11bb22-cc33-4d44-8e55-6f7788990011', + timestamp: '2026-08-05T10:00:09.000Z', + sessionId: CLAUDE_SESSION + } + ]) + const journal = await open('claude', CLAUDE_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath, limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 1_024 } } + }) + + const item = journal.snapshot().items[0] + expect(item?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) + const body = item?.body + if (body?.kind !== 'tool-call' || !body.output) { + throw new Error('expected a bounded tool-call output') + } + expect(body.output.truncated).toBe(true) + expect(body.output.byteLength).toBe(64 * 1024) + expect(body.output.head).toHaveLength(1_024) + expect(await readJournalBlob(root, body.output.digest)).toBe(output) + }) +}) + +describe('import failures', () => { + it('keeps the live epoch intact when a staged rebuild runs out of budget', async () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 } + const journal = await open('codex', CODEX_SESSION, { limits }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + messages: [ + { + id: 'durable-prefix', + role: 'assistant', + blocks: [{ type: 'text', text: 'keep me' }], + timestamp: 1_800_000_000_000, + source: 'transcript' + } + ] + }) + const filePath = await writeFixture('oversized-rollout.jsonl', [ + CODEX_LINES[0], + CODEX_LINES[1], + CODEX_LINES[2], + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:03.000Z', + payload: { type: 'agent_message', message: 'x'.repeat(2_000) } + } + ]) + const epoch = journal.epoch + const snapshotPath = join(root, 'snapshot.json') + const logPath = join(root, 'log.jsonl') + const before = { + snapshot: await readFile(snapshotPath, 'utf-8'), + log: await readFile(logPath, 'utf-8') + } + + await expect( + importLegacyTranscriptIntoJournal({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + options: { filePath, limits } + }) + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + expect(journal.epoch).toBe(epoch) + expect(await readFile(snapshotPath, 'utf-8')).toBe(before.snapshot) + expect(await readFile(logPath, 'utf-8')).toBe(before.log) + expect(journal.snapshot().items[0]?.body).toMatchObject({ + kind: 'message', + blocks: [{ type: 'text', text: 'keep me' }] + }) + }) + + it('reports a missing transcript without touching the journal', async () => { + const journal = await open('claude', CLAUDE_SESSION) + const before = journal.epoch + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath: join(root, 'missing.jsonl') } + }) + expect(result).toMatchObject({ ok: false }) + expect(journal.epoch).toBe(before) + }) + + it('rejects an agent with no transcript decoder', async () => { + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'gemini', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath: join(root, 'claude.jsonl') } + }) + expect(result).toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts new file mode 100644 index 00000000000..f72e34c937a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -0,0 +1,241 @@ +// Hydrating a journal from a bridge-era transcript. +// +// This reuses the existing per-agent transcript decoders verbatim — a second +// parser would drift from the one the live view already uses. The decoders +// return a render model with no identity, so the import wraps them: the wrapper +// reads an identity anchor off the SAME raw line, then delegates the content. +// +// Import always opens a fresh epoch. The imported timeline is a best-effort +// reconstruction with import-scoped identities for most providers, so it must +// never be spliced into a sequence space that a structured session is also +// writing; a later structured resume rolls the epoch again and rebuilds. + +import { createReadStream } from 'node:fs' +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentJournalCursor, + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from '../session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine, + decodeOmpTranscriptLine +} from '../transcript-line-decoders' +import { decodeTranscriptStream } from '../transcript-stream-lines' +import { putJournalBlob } from './journal-blob-store' +import { createLegacyIdentityTracker } from './journal-legacy-identity' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + type JournalPayloadLimits +} from './journal-payload-bounds' +import type { AgentSessionJournal } from './journal-store' + +export type LegacyImportOptions = ResolveSessionFileOptions & { + /** Resolve directly to this file, skipping path discovery. */ + filePath?: string + limits?: JournalPayloadLimits + decodedMessageIdentities?: true +} + +export type LegacyImportResult = + | { ok: true; epoch: string; cursor: AgentJournalCursor; imported: number } + | { ok: false; error: string } + +export async function appendLegacyTranscriptMessages(input: { + journal: AgentSessionJournal + agent: AgentType + sessionId: string + fence: number + messages: NativeChatMessage[] +}): Promise { + let appended = 0 + for (const message of input.messages) { + const mapped = legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + for (const blob of mapped.blobs) { + await putJournalBlob(input.journal.directory, blob.digest, blob.payload) + } + await input.journal.appendItem( + { + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: message.id + }, + mapped.body, + { fence: input.fence, observedAt: message.timestamp ?? undefined } + ) + appended += 1 + } + return appended +} + +export async function importLegacyTranscriptIntoJournal(input: { + journal: AgentSessionJournal + agent: AgentType + sessionId: string + fence: number + options?: LegacyImportOptions +}): Promise { + const options = input.options ?? {} + const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS + const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) + if (!transcriptAgent) { + return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } + } + const filePath = + options.filePath ?? (await resolveSessionFilePath(input.agent, input.sessionId, options)) + if (!filePath) { + return { ok: false, error: `No transcript found for ${input.agent} session ${input.sessionId}` } + } + + let decoded: { messages: NativeChatMessage[]; identities: AgentJournalItemIdentity[] } + try { + decoded = await decodeWithIdentities({ + filePath, + transcriptAgent, + agent: input.agent, + sessionId: input.sessionId, + decodedMessageIdentities: options.decodedMessageIdentities + }) + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + + const replacement: JournalReplacementItem[] = [] + for (const [index, message] of decoded.messages.entries()) { + const identity = decoded.identities[index] + if (!identity) { + continue + } + const mapped = legacyItemBody(message, limits) + for (const blob of mapped.blobs) { + await putJournalBlob(input.journal.directory, blob.digest, blob.payload) + } + replacement.push({ + identity, + body: mapped.body, + observedAt: message.timestamp ?? undefined + }) + } + const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement) + return { ok: true, epoch: cursor.epoch, cursor, imported: decoded.messages.length } +} + +const TRANSCRIPT_DECODERS = { + claude: decodeClaudeTranscriptLine, + codex: decodeCodexTranscriptLine, + grok: decodeGrokTranscriptLine, + omp: decodeOmpTranscriptLine +} as const + +/** Run the real decoder while recording an identity anchor per emitted message, + * index-aligned with `messages`. */ +async function decodeWithIdentities(input: { + filePath: string + transcriptAgent: keyof typeof TRANSCRIPT_DECODERS + agent: AgentType + sessionId: string + decodedMessageIdentities?: true +}): Promise<{ messages: NativeChatMessage[]; identities: AgentJournalItemIdentity[] }> { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: input.transcriptAgent, + agent: input.agent, + sessionId: input.sessionId + }) + const decode = TRANSCRIPT_DECODERS[input.transcriptAgent] + const identities: AgentJournalItemIdentity[] = [] + let lineIndex = 0 + + const stream = createReadStream(input.filePath, { encoding: 'utf-8' }) + const { messages } = await decodeTranscriptStream( + stream, + input.filePath, + 0, + (line, fallbackId) => { + const trackedIdentity = tracker.identify(line, lineIndex) + lineIndex += 1 + const message = decode(line, fallbackId) + if (message) { + identities.push( + input.decodedMessageIdentities + ? { + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: message.id + } + : trackedIdentity + ) + } + return message + }, + true + ) + return { messages, identities } +} + +type MappedLegacyItem = { + body: AgentJournalItemBody + blobs: { digest: string; payload: string }[] +} + +/** + * A message whose only content is a tool invocation becomes a tool-call item so + * the reducer renders it as one. Everything else stays a message item with its + * blocks bounded in place. + */ +function legacyItemBody( + message: NativeChatMessage, + limits: JournalPayloadLimits +): MappedLegacyItem { + const only = message.blocks.length === 1 ? message.blocks[0] : undefined + if (only?.type === 'tool-call') { + return { + body: { kind: 'tool-call', name: only.name, input: only.input, state: 'completed' }, + blobs: [] + } + } + if (only?.type === 'tool-result') { + const output = boundPayload(only.output, limits) + return { + body: { + kind: 'tool-call', + name: 'tool-result', + input: null, + state: only.isError ? 'failed' : 'completed', + output + }, + blobs: output.truncated ? [{ digest: output.digest, payload: only.output }] : [] + } + } + return { + body: { + kind: 'message', + role: message.role, + blocks: message.blocks.map((block) => boundBlock(block, limits)) + }, + blobs: [] + } +} + +/** Inline block text keeps only a bounded head plus an explicit marker. No blob + * is written: the marker carries the digest and byte length, and the source + * transcript remains the full copy — a blob here would be unreferenced by the + * render model and pruned at the next compaction. */ +function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock { + if (block.type === 'text') { + return { ...block, text: boundInlineText(block.text, limits).text } + } + if (block.type === 'tool-result') { + return { ...block, output: boundInlineText(block.output, limits).text } + } + return block +} diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.test.ts b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts new file mode 100644 index 00000000000..ff710db67ed --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts @@ -0,0 +1,374 @@ +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendJournalRows, JOURNAL_SNAPSHOT_FILE, readJournalSnapshot } from './journal-log-file' +import type { JournalSnapshotFile } from './journal-log-file' +import type { JournalRow } from './journal-row-schema' +import { openAgentSessionJournal } from './journal-store' +import { + projectStructuredAgentSessionStatus, + projectStructuredItemsToNativeChat +} from '../../../shared/structured-agent-session-projection' + +type FakeDirectoryHandle = { sync: ReturnType; close: ReturnType } + +let openDirectoryHook: ((path: unknown, flags: unknown) => FakeDirectoryHandle | undefined) | null = + null + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: (async (...args: Parameters) => { + const fake = openDirectoryHook?.(args[0], args[1]) + return fake ?? actual.open(...args) + }) as typeof actual.open + } +}) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-log-file-')) + openDirectoryHook = null +}) + +afterEach(async () => { + openDirectoryHook = null + await rm(root, { recursive: true, force: true }) +}) + +function validSnapshot(): JournalSnapshotFile { + return { + v: 1, + epoch: 'epoch-A', + compactedThrough: 2, + highestFence: 1, + items: [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + sequence: 2, + observedAt: 1_000 + } + ], + submissions: [], + receipts: [], + aliases: [], + tombstones: [{ itemId: 'codex:thread-1:turn-1:2', revision: 3 }], + tail: [] + } +} + +async function writeSnapshot(snapshot: unknown): Promise { + await writeFile(join(root, JOURNAL_SNAPSHOT_FILE), JSON.stringify(snapshot), 'utf-8') +} + +describe('readJournalSnapshot validation', () => { + it('accepts a well-formed snapshot, with and without the tombstones collection', async () => { + await writeSnapshot(validSnapshot()) + expect((await readJournalSnapshot(root)).status).toBe('valid') + + const { tombstones: _tombstones, ...withoutTombstones } = validSnapshot() + await writeSnapshot(withoutTombstones) + expect((await readJournalSnapshot(root)).status).toBe('valid') + }) + + it('accepts every canonical item kind and a fully-formed submission', async () => { + const snapshot = validSnapshot() + const payload = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true } + snapshot.items = [ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { + kind: 'tool-call', + name: 'Read', + input: { path: 'a' }, + state: 'completed', + output: payload + }, + { kind: 'diff', path: 'a.ts', patch: payload }, + { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + freeTextQuestionId: 'q-free', + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 5 } + }, + { + kind: 'status', + text: 'working', + turnLifecycle: { turnId: 'turn-1', state: 'running' }, + providerFrame: { provider: 'codex', kind: 'raw', payload } + } + ].map((body, index) => ({ + itemId: `codex:thread-1:turn-1:${index + 1}`, + revision: 1, + body: body as JournalSnapshotFile['items'][number]['body'], + sequence: index + 1, + observedAt: 1_000, + ...(index === 0 ? { recovered: true as const } : {}) + })) + snapshot.compactedThrough = snapshot.items.length + snapshot.submissions = [ + { + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'accepted', + providerItemId: 'codex:thread-1:turn-1:1', + reason: null, + submittedAt: 1_000, + resolvedAt: 1_001 + } + ] + await writeSnapshot(snapshot) + expect((await readJournalSnapshot(root)).status).toBe('valid') + }) + + it('classifies a JSON-valid non-array tombstones collection as invalid instead of valid', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects tombstone entries that would poison seeding', async () => { + for (const tombstones of [ + [{ itemId: 42, revision: 1 }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: 'one' }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: Number.NaN }], + ['codex:thread-1:turn-1:1'] + ]) { + await writeSnapshot({ ...validSnapshot(), tombstones }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + } + }) + + it('rejects JSON-valid nested item corruption instead of admitting it', async () => { + // A resolved question with `options: null` used to pass shallow admission and + // then throw `TypeError` in the shared projection's `options.map`. + const poisonedQuestion = validSnapshot() + poisonedQuestion.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(poisonedQuestion) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + + // Pending-prompt surfaces read `resolution.state` before anything else. + const nullResolution = validSnapshot() + nullResolution.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'question', question: 'Deploy?', options: [], resolution: null }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(nullResolution) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects a JSON-valid nested corruption in the retained tail', async () => { + const poisonedTail = validSnapshot() + poisonedTail.tail = [ + { + v: 1, + epoch: 'epoch-A', + seq: 3, + fence: 1, + ts: 1_000, + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + } + } + ] as unknown as JournalSnapshotFile['tail'] + await writeSnapshot(poisonedTail) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects a submission that only carries a client message id', async () => { + const shallowSubmission = validSnapshot() + shallowSubmission.submissions = [ + { clientMessageId: 'm-1' } + ] as unknown as JournalSnapshotFile['submissions'] + await writeSnapshot(shallowSubmission) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects items and counters that only look shallowly plausible', async () => { + const missingSequence = validSnapshot() + missingSequence.items = [ + { itemId: 'codex:thread-1:turn-1:1', revision: 1, body: { kind: 'status', text: 'x' } } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(missingSequence) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + + await writeSnapshot({ ...validSnapshot(), compactedThrough: Number.NaN }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) +}) + +describe('future-version snapshot classification', () => { + it('classifies a future version before shape validation so unknown bodies stay unreadable', async () => { + // The version can only advance because bodies changed, so a future snapshot + // legitimately carries kinds this build cannot parse. That is the + // schema-unreadable contract, not corruption. + const future = validSnapshot() as unknown as Record + future.v = 99 + future.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 2, + observedAt: 1_000 + } + ] + await writeSnapshot(future) + expect((await readJournalSnapshot(root)).status).toBe('unreadable') + }) + + it('classifies a future version as unreadable even when its shapes still parse today', async () => { + await writeSnapshot({ ...validSnapshot(), v: 99 }) + expect((await readJournalSnapshot(root)).status).toBe('unreadable') + }) + + it('treats a non-integer or sub-1 version as invalid, matching row admission', async () => { + for (const v of [0, 1.5]) { + await writeSnapshot({ ...validSnapshot(), v }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + } + }) +}) + +describe('journal startup isolation from a malformed snapshot', () => { + it('quarantines a JSON-valid malformed snapshot instead of throwing through open', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) + + // Degraded exactly like other corrupt snapshots: quarantined on disk, never + // silently deleted, and the session does not adopt state it cannot trust. + const entries = await readdir(root) + expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true) + expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(false) + expect(journal.snapshot().items).toEqual([]) + }) +}) + +describe('reopen after a persisted JSON-valid poisoned question', () => { + it('quarantines the snapshot so reopen-to-render cannot throw in projection', async () => { + const poisoned = validSnapshot() + poisoned.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(poisoned) + + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) + + // The poisoned item must land in quarantine, not in the reopened state: + // pre-fix it was admitted and the render path below threw + // `TypeError: Cannot read properties of null (reading 'map')`. + const entries = await readdir(root) + expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true) + const items = journal.snapshot().items + expect(() => projectStructuredItemsToNativeChat(items)).not.toThrow() + expect(() => projectStructuredAgentSessionStatus(items)).not.toThrow() + expect(items).toEqual([]) + }) +}) + +describe('appendJournalRows directory fsync', () => { + const ROW: JournalRow = { + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + v: 1, + epoch: 'epoch-A', + seq: 1, + fence: 0, + ts: 1_000 + } + + function hookDirectoryOpen(sync: ReturnType): FakeDirectoryHandle { + const fake: FakeDirectoryHandle = { sync, close: vi.fn(async () => undefined) } + openDirectoryHook = (path, flags) => (path === root && flags === 'r' ? fake : undefined) + return fake + } + + it('closes the directory handle when directory fsync fails', async () => { + const fake = hookDirectoryOpen( + vi.fn(async () => { + throw new Error('EINVAL: sync') + }) + ) + + // Tolerating unsupported directory fsync must not turn into a leak. + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) + + it('closes the directory handle when directory fsync succeeds', async () => { + const fake = hookDirectoryOpen(vi.fn(async () => undefined)) + + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.ts b/src/main/native-chat/agent-session-journal/journal-log-file.ts new file mode 100644 index 00000000000..b556177b889 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-log-file.ts @@ -0,0 +1,336 @@ +// On-disk layout for one session's journal. +// +// /log.jsonl append-only rows, fsynced before the caller is told the write landed +// /snapshot.json folded state at a compaction boundary PLUS the retained tail +// /blobs/ bounded-payload remainders +// +// The snapshot carries its own tail so compaction is one atomic write. A crash +// between publishing the snapshot and truncating the log leaves the log a +// superset of the tail, and recovery unions the two by sequence — never a hole. + +import { appendFile, mkdir, open, readFile, type FileHandle } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { durableWriteTempPath, renameDurable, writeFileDurable } from '../../durable-file-write' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalRenderItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { + isAdmissibleAgentJournalRenderItem, + isAdmissibleAgentJournalSubmission +} from '../../../shared/agent-session-journal-schemas' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' + +export const JOURNAL_LOG_FILE = 'log.jsonl' +export const JOURNAL_SNAPSHOT_FILE = 'snapshot.json' + +export type JournalSnapshotFile = { + v: number + epoch: string + /** Highest sequence folded into `items`; the tail starts after it. */ + compactedThrough: number + /** Fence monotonicity survives compaction and restart. */ + highestFence: number + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + /** Receipts outlive the rows that minted them: a client reconnecting after + * compaction must still get the same answer instead of re-sending. */ + receipts: { + clientMessageId: string + providerItemId: string + epoch: string + sequence: number + acceptedAt: number + }[] + /** Provider item id → submission slot, preserved so a post-compaction echo + * still reconciles into the bubble it belongs to. */ + aliases: { providerItemId: string; itemId: string }[] + tombstones: { itemId: string; revision: number }[] + tail: JournalRow[] +} + +export type JournalReadResult = { + rows: JournalRow[] + /** True when a line used a schema version this build cannot read. Reading + * STOPS there — the row must not be skipped — and the host degrades to + * read-only: no writes, no compaction, no deletion. */ + unreadable: boolean + /** Lines that failed to parse for reasons other than schema version. */ + malformed: number + /** Raw suffix beginning at the first malformed line, if any. */ + remainder?: string + /** Distinguishes an absent/empty log from bytes that could not name an epoch. */ + hasBytes: boolean +} + +export type JournalSnapshotReadResult = + | { status: 'missing' } + | { status: 'valid'; snapshot: JournalSnapshotFile } + | { status: 'invalid' } + /** A future schema version: unreadable by this build, not corrupt. The file + * stays authoritative in place and the caller degrades to read-only. */ + | { status: 'unreadable' } + +const NEWLINE_BYTE = 0x0a + +export async function ensureJournalDir(journalDir: string): Promise { + await mkdir(journalDir, { recursive: true }) +} + +export async function readJournalSnapshot(journalDir: string): Promise { + try { + const raw = await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8') + const parsed: unknown = JSON.parse(raw) + const version = snapshotSchemaVersion(parsed) + if (version === null) { + return { status: 'invalid' } + } + // Version is classified BEFORE shape validation, matching row admission: a + // version only advances because bodies changed, so a valid newer snapshot + // carries kinds this build cannot parse — unreadable, never corruption. + if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + return { status: 'unreadable' } + } + return isJournalSnapshotFile(parsed) + ? { status: 'valid', snapshot: parsed } + : { status: 'invalid' } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'missing' } + } + if (error instanceof SyntaxError) { + return { status: 'invalid' } + } + throw error + } +} + +export async function quarantineInvalidJournalSnapshot(journalDir: string): Promise { + const source = join(journalDir, JOURNAL_SNAPSHOT_FILE) + const target = join(journalDir, `quarantine-snapshot-${Date.now()}-${randomUUID()}.json`) + await renameDurable(source, target) + return target +} + +export async function writeJournalSnapshotFile( + journalDir: string, + snapshot: JournalSnapshotFile +): Promise { + const target = join(journalDir, JOURNAL_SNAPSHOT_FILE) + await writeFileDurable(durableWriteTempPath(target), target, JSON.stringify(snapshot)) +} + +export async function readJournalLog(journalDir: string): Promise { + let raw: string + try { + raw = await readFile(join(journalDir, JOURNAL_LOG_FILE), 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { rows: [], unreadable: false, malformed: 0, hasBytes: false } + } + throw error + } + const rows: JournalRow[] = [] + let unreadable = false + let malformed = 0 + const lines = raw.split('\n') + let offset = 0 + for (const line of lines) { + if (!line.trim()) { + offset += line.length + 1 + continue + } + const parsed = parseJournalRow(line) + if (parsed.ok) { + rows.push(parsed.row) + offset += line.length + 1 + continue + } + if (parsed.unreadable) { + unreadable = true + return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 } + } + malformed += 1 + return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 } + } + return { rows, unreadable, malformed, hasBytes: raw.length > 0 } +} + +/** Row admission requires an integer version of at least 1; a snapshot whose + * version cannot even be read is malformed, not a schema statement. */ +function snapshotSchemaVersion(value: unknown): number | null { + const snapshot = recordOf(value) + const version = snapshot?.v + return typeof version === 'number' && Number.isInteger(version) && version >= 1 ? version : null +} + +function isJournalSnapshotFile(value: unknown): value is JournalSnapshotFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const snapshot = value as Record + return ( + typeof snapshot.v === 'number' && + typeof snapshot.epoch === 'string' && + snapshot.epoch.length > 0 && + Number.isInteger(snapshot.compactedThrough) && + (snapshot.compactedThrough as number) >= 0 && + Number.isInteger(snapshot.highestFence) && + // Deep discriminated admission: a JSON-valid item with a corrupt nested + // shape (e.g. a question whose options are null) must land this snapshot + // in quarantine rather than throw later in projection or prompt render. + arrayOf(snapshot.items, isAdmissibleAgentJournalRenderItem) && + arrayOf(snapshot.submissions, isAdmissibleAgentJournalSubmission) && + arrayOf(snapshot.receipts, isReceipt) && + arrayOf(snapshot.aliases, isAlias) && + // Older snapshots predate tombstones; absence is fine, a non-array is not — + // seeding iterates this collection, so a JSON-valid wrong shape must land + // in quarantine rather than throw through startup restoration. + (snapshot.tombstones === undefined || arrayOf(snapshot.tombstones, isTombstone)) && + arrayOf(snapshot.tail, (row) => parseJournalRow(JSON.stringify(row)).ok) + ) +} + +function arrayOf(value: unknown, predicate: (entry: unknown) => boolean): value is unknown[] { + return Array.isArray(value) && value.every(predicate) +} + +function recordOf(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function isTombstone(value: unknown): boolean { + const tombstone = recordOf(value) + return Boolean( + tombstone && typeof tombstone.itemId === 'string' && Number.isInteger(tombstone.revision) + ) +} + +function isReceipt(value: unknown): boolean { + const receipt = recordOf(value) + return Boolean( + receipt && + typeof receipt.clientMessageId === 'string' && + typeof receipt.providerItemId === 'string' && + typeof receipt.epoch === 'string' && + typeof receipt.sequence === 'number' && + typeof receipt.acceptedAt === 'number' + ) +} + +function isAlias(value: unknown): boolean { + const alias = recordOf(value) + return Boolean( + alias && typeof alias.providerItemId === 'string' && typeof alias.itemId === 'string' + ) +} + +/** + * Append rows and fsync before returning. The caller treats a resolved promise + * as "this row survives a power loss" — the write-ahead submission row depends + * on exactly that, so this must never be relaxed to a buffered write. + */ +export async function appendJournalRows( + journalDir: string, + rows: readonly JournalRow[] +): Promise { + if (rows.length === 0) { + return + } + const path = join(journalDir, JOURNAL_LOG_FILE) + // A process death can leave a final JSON fragment without its newline. Never + // concatenate a new durable row onto that fragment: truncate the torn tail + // first, then fsync the repair before acknowledging this append. + try { + await repairJournalLogTail(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + // The append below creates a missing log. + } + const payload = `${rows.map(serializeJournalRow).join('\n')}\n` + await appendFile(path, payload, 'utf-8') + const handle = await open(path, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + let directory: FileHandle | undefined + try { + directory = await open(journalDir, 'r') + await directory.sync() + } catch { + // Directory fsync is unavailable on some platforms (notably Windows). + } finally { + // The tolerance above must not leak the descriptor when open succeeded + // but sync failed — one leaked handle per append adds up fast. + await directory?.close().catch(() => undefined) + } +} + +/** Repair only a torn final row. The normal append path reads one byte; scanning + * backward is reserved for the crash-recovery case and never rereads the log. */ +async function repairJournalLogTail(path: string): Promise { + const handle = await open(path, 'r+') + try { + const { size } = await handle.stat() + if (size === 0) { + return + } + const lastByte = Buffer.alloc(1) + await handle.read(lastByte, 0, 1, size - 1) + if (lastByte[0] === NEWLINE_BYTE) { + return + } + + const scanChunkBytes = 64 * 1024 + let scanEnd = size + let boundary = -1 + while (scanEnd > 0 && boundary === -1) { + const scanStart = Math.max(0, scanEnd - scanChunkBytes) + const chunk = Buffer.alloc(scanEnd - scanStart) + await handle.read(chunk, 0, chunk.length, scanStart) + const newline = chunk.lastIndexOf(NEWLINE_BYTE) + if (newline !== -1) { + boundary = scanStart + newline + } + scanEnd = scanStart + } + + const lineStart = boundary + 1 + const finalLine = Buffer.alloc(size - lineStart) + await handle.read(finalLine, 0, finalLine.length, lineStart) + // A whole row that merely lost its newline is kept; a real fragment goes. + const complete = parseJournalRow(finalLine.toString('utf-8')).ok + await (complete ? handle.write('\n', size) : handle.truncate(lineStart)) + await handle.sync() + } finally { + await handle.close() + } +} + +export async function quarantineJournalRemainder( + journalDir: string, + remainder: string +): Promise { + const path = join(journalDir, `quarantine-${Date.now()}-${randomUUID()}.jsonl`) + await writeFileDurable(durableWriteTempPath(path), path, remainder) + return path +} + +/** Replace the log with exactly the retained tail. Runs only after the snapshot + * carrying that tail is durable, so a crash here loses nothing. */ +export async function rewriteJournalLog( + journalDir: string, + rows: readonly JournalRow[] +): Promise { + const target = join(journalDir, JOURNAL_LOG_FILE) + const payload = rows.length ? `${rows.map(serializeJournalRow).join('\n')}\n` : '' + await writeFileDurable(durableWriteTempPath(target), target, payload) +} diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts new file mode 100644 index 00000000000..0dbee7b4005 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -0,0 +1,186 @@ +// Loading a journal from disk: snapshot + log → folded state. +// +// The snapshot is authoritative for the current epoch. Log rows belonging to a +// superseded epoch are dropped rather than merged — a crash between publishing +// a rollover snapshot and rewriting the log is the ordinary way that happens. +// A gap in the surviving sequence is corruption, and the caller rolls the epoch +// rather than rendering a partial timeline. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { findSequenceGap } from './journal-cursor' +import { + quarantineInvalidJournalSnapshot, + readJournalLog, + readJournalSnapshot, + type JournalSnapshotFile +} from './journal-log-file' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import { journalRowByteLength, type JournalRow } from './journal-row-schema' + +export type JournalLoad = { + state: JournalReducerState + /** Rows still individually replayable, oldest first. */ + tailRows: JournalRow[] + /** Highest sequence folded into the snapshot; the tail starts after it. */ + compactedThrough: number + /** A future schema version was met: no writes, no compaction, no deletion. */ + readOnly: boolean + /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ + corrupt: boolean + /** Log lines skipped because they failed to parse (schema-version rows are + * `readOnly`, never counted here). The store discloses these in the timeline. */ + malformedRows: number + sizeBytes: number + /** Raw unreadable suffix retained for quarantine instead of deletion. */ + quarantineRemainder?: string +} + +/** Returns null when no journal exists yet for this session. */ +export async function loadJournal( + journalDir: string, + sessionId: string +): Promise { + const snapshotRead = await readJournalSnapshot(journalDir) + if (snapshotRead.status === 'unreadable') { + // Written by a newer schema: not corrupt, so never quarantined. The file + // stays authoritative in place, and this build must not write, compact, + // delete, or render a partial timeline from rows it cannot anchor to the + // snapshot it cannot read. + return emptyReadOnlyLoad(sessionId) + } + if (snapshotRead.status === 'invalid') { + await quarantineInvalidJournalSnapshot(journalDir) + } + const snapshot = snapshotRead.status === 'valid' ? snapshotRead.snapshot : null + const log = await readJournalLog(journalDir) + const epoch = resolveEpoch(snapshot, log.rows) + if (!epoch) { + return snapshotRead.status === 'invalid' || log.hasBytes ? emptyReadOnlyLoad(sessionId) : null + } + + const compactedThrough = snapshot?.epoch === epoch ? snapshot.compactedThrough : 0 + const state = seedState(sessionId, epoch, snapshot?.epoch === epoch ? snapshot : null) + const liveRows = log.rows.filter((row) => row.epoch === epoch) + let tailRows = unionBySequence(snapshot?.epoch === epoch ? snapshot.tail : [], liveRows, epoch) + + const oldest = tailRows[0]?.seq ?? compactedThrough + 1 + const gap = findSequenceGap( + tailRows.map((row) => row.seq), + oldest + ) + // A hole below the snapshot boundary is unrecoverable too: the snapshot only + // covers `compactedThrough`, so a tail that starts above it lost rows. + let corrupt = Boolean(gap) || oldest > compactedThrough + 1 || log.malformed > 0 + let quarantineRemainder = log.remainder + if (gap) { + const firstBad = tailRows.findIndex((row, index) => { + const expected = (tailRows[0]?.seq ?? compactedThrough + 1) + index + return row.seq !== expected + }) + if (firstBad !== -1) { + const suffix = tailRows.slice(firstBad) + tailRows = tailRows.slice(0, firstBad) + quarantineRemainder ??= `${suffix.map((row) => JSON.stringify(row)).join('\n')}\n` + } + } + + for (const row of tailRows) { + if (row.seq > compactedThrough) { + applyJournalRow(state, row) + } + } + state.oldestSequence = oldest + state.lastSequence = Math.max(state.lastSequence, compactedThrough) + + return { + state, + tailRows, + compactedThrough, + // A future-version snapshot never reaches here: it is classified + // unreadable above, so `valid` implies a version this build can write. + readOnly: log.unreadable, + corrupt, + malformedRows: log.malformed, + sizeBytes: tailRows.reduce((total, row) => total + journalRowByteLength(row), 0), + quarantineRemainder + } +} + +function emptyReadOnlyLoad(sessionId: string): JournalLoad { + const state = createJournalReducerState(sessionId, '') + return { + state, + tailRows: [], + compactedThrough: 0, + readOnly: true, + corrupt: false, + malformedRows: 0, + sizeBytes: 0 + } +} + +/** The snapshot names the live epoch; without one, the newest valid row does. */ +function resolveEpoch(snapshot: JournalSnapshotFile | null, rows: JournalRow[]): string | null { + if (snapshot?.epoch) { + return snapshot.epoch + } + return rows.at(-1)?.epoch ?? null +} + +function seedState( + sessionId: string, + epoch: string, + snapshot: JournalSnapshotFile | null +): JournalReducerState { + const state = createJournalReducerState(sessionId, epoch) + if (!snapshot) { + return state + } + for (const item of snapshot.items) { + state.items.set(item.itemId, item) + } + for (const submission of snapshot.submissions) { + state.submissions.set(submission.clientMessageId, { ...submission } as AgentJournalSubmission) + } + for (const receipt of snapshot.receipts) { + state.receipts.set(receipt.clientMessageId, { + clientMessageId: receipt.clientMessageId, + providerItemId: receipt.providerItemId, + cursor: { epoch: receipt.epoch, sequence: receipt.sequence }, + acceptedAt: receipt.acceptedAt + }) + } + for (const alias of snapshot.aliases) { + state.aliases.set(alias.providerItemId, alias.itemId) + } + for (const tombstone of snapshot.tombstones ?? []) { + state.tombstones.set(tombstone.itemId, tombstone.revision) + } + state.highestFence = snapshot.highestFence ?? 0 + state.lastSequence = snapshot.compactedThrough + state.oldestSequence = snapshot.compactedThrough + 1 + return state +} + +/** Merge the snapshot's retained tail with the live log, preferring the log's + * copy of any sequence both hold, and dropping rows from a superseded epoch. */ +function unionBySequence( + retained: readonly JournalRow[], + live: readonly JournalRow[], + epoch: string +): JournalRow[] { + const bySequence = new Map() + for (const row of retained) { + if (row.epoch === epoch) { + bySequence.set(row.seq, row) + } + } + for (const row of live) { + bySequence.set(row.seq, row) + } + return [...bySequence.values()].sort((a, b) => a.seq - b.seq) +} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts new file mode 100644 index 00000000000..87616b25b96 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -0,0 +1,42 @@ +// Where a session journal lives. +// +// Host-side per-workspace state, keyed by workspace id — never inside the +// user's working tree. A journal in the tree would show up in `git status`, +// vanish with `git worktree remove`, and have no defined home in a folder +// workspace that is not a repository at all. Keying by id rather than by path +// makes a worktree, a folder workspace, a WSL distro, and an SSH host identical. +// +// The host environment port supplies the root so desktop Electron and the +// headless/SSH runtime resolve their own durable state directories. + +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { getAppEnvironment } from '../../../shared/app-environment' + +const JOURNAL_DIR_NAME = 'agent-session-journal' + +/** Filesystem-safe, collision-resistant segment for an arbitrary id. Ids come + * from providers and workspaces and can contain path separators or characters + * Windows rejects, so they are hashed rather than sanitized. */ +export function journalPathSegment(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) +} + +/** `/agent-session-journal//`. */ +export function journalDirectoryFor( + root: string, + identity: Pick +): string { + return join( + root, + JOURNAL_DIR_NAME, + journalPathSegment(identity.workspaceId), + journalPathSegment(identity.sessionId) + ) +} + +/** Default host state root. */ +export function defaultJournalRoot(): Promise { + return Promise.resolve(getAppEnvironment().getPath('userData')) +} diff --git a/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts new file mode 100644 index 00000000000..61cb82894a4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts @@ -0,0 +1,86 @@ +// Payload bounds for tool output and diffs. +// +// A looping agent must not be able to fill the host disk, and a 40 MB tool +// result must not be inlined into a row that every reconnecting client +// replays. A bounded payload keeps a head plus the original byte length and +// digest; the remainder lives in the content-addressed blob store under the +// same retention as its epoch. Crossing a bound is always marked — never a +// silent drop. + +import { createHash } from 'node:crypto' +import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types' + +export type JournalPayloadLimits = { + /** Bytes of the payload kept inline on the row. */ + inlineHeadBytes: number + /** Total bytes of journal rows one session may hold before appends are refused. */ + maxSessionBytes: number + /** Appends allowed inside `appendWindowMs`, bounding a runaway agent's rate. */ + maxAppendsPerWindow: number + appendWindowMs: number +} + +export const DEFAULT_JOURNAL_PAYLOAD_LIMITS: JournalPayloadLimits = { + inlineHeadBytes: 16 * 1024, + maxSessionBytes: 256 * 1024 * 1024, + maxAppendsPerWindow: 5000, + appendWindowMs: 60_000 +} + +/** Marker appended to a clipped inline string so the UI never presents a + * truncated body as complete. Kept in the text itself because block-level + * payloads (tool-result output) have nowhere else to carry the flag. */ +export function journalTruncationMarker(byteLength: number, digest: string): string { + return `\n[Orca: output truncated — ${byteLength} bytes total, digest ${digest.slice(0, 12)}]` +} + +export function digestPayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex') +} + +/** + * Clip `payload` to the inline head. `truncated` means the remainder must be + * written to the blob store under `digest` before the row is appended. + */ +export function boundPayload( + payload: string, + limits: JournalPayloadLimits +): AgentJournalBoundedPayload { + const buffer = Buffer.from(payload, 'utf8') + const digest = digestPayload(payload) + if (buffer.byteLength <= limits.inlineHeadBytes) { + return { head: payload, byteLength: buffer.byteLength, digest, truncated: false } + } + return { + head: clipUtf8(buffer, limits.inlineHeadBytes), + byteLength: buffer.byteLength, + digest, + truncated: true + } +} + +/** Bound a plain string that must stay a string (a tool-result block's output), + * keeping the explicit marker inline. Returns the blob payload to persist. */ +export function boundInlineText( + payload: string, + limits: JournalPayloadLimits +): { text: string; bounded: AgentJournalBoundedPayload } { + const bounded = boundPayload(payload, limits) + if (!bounded.truncated) { + return { text: payload, bounded } + } + return { + text: bounded.head + journalTruncationMarker(bounded.byteLength, bounded.digest), + bounded + } +} + +/** Slice at a byte budget without splitting a multi-byte character. */ +function clipUtf8(buffer: Buffer, maxBytes: number): string { + let end = maxBytes + // A UTF-8 continuation byte is 0b10xxxxxx; walk back off a split sequence. + while (end > 0 && (buffer[end] & 0b1100_0000) === 0b1000_0000) { + end -= 1 + } + return buffer.subarray(0, end).toString('utf8') +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts new file mode 100644 index 00000000000..76bc00394f2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -0,0 +1,18 @@ +import type { AgentSessionJournal } from './journal-store' + +export async function markJournalPendingSubmissionsUnknown( + journal: AgentSessionJournal, + fence: number +): Promise { + const pending = journal.pendingSubmissions().map((entry) => entry.clientMessageId) + for (const clientMessageId of pending) { + await journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: 'host_restarted_before_acknowledgement', + fence, + recovered: true + }) + } + return pending +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts new file mode 100644 index 00000000000..832b25097b5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + applyJournalRow, + createJournalReducerState, + referencedBlobDigests, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function userText(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text: value }] } +} + +function sendFingerprint(body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body } + }) +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +function fold(rows: JournalRow[]): JournalReducerState { + const state = createJournalReducerState('session-1', EPOCH) + for (const row of rows) { + applyJournalRow(state, row) + } + return state +} + +describe('revisions and tombstones', () => { + it('takes the highest revision', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('first'), ...base(1) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('second'), ...base(2) } + ]) + expect(renderJournalState(state).items[0]?.body).toEqual(text('second')) + }) + + it('drops a late lower revision instead of resurrecting stale content', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 2, body: text('second'), ...base(1) }, + { kind: 'item', itemId: 'a', revision: 1, body: text('first'), ...base(2) } + ]) + expect(renderJournalState(state).items[0]?.body).toEqual(text('second')) + }) + + it('removes an item on a tombstone', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 2, ...base(2) } + ]) + expect(renderJournalState(state).items).toHaveLength(0) + }) + + it('does not let a late lower revision resurrect a tombstoned item', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 3, ...base(2) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('stale'), ...base(3) } + ]) + expect(renderJournalState(state).items).toHaveLength(0) + }) + + it('re-creates an item at a revision above the tombstone', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 2, ...base(2) }, + { kind: 'item', itemId: 'a', revision: 3, body: text('back'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('back')]) + }) +}) + +describe('ordering', () => { + it('orders by the sequence that created an item, not by a later revision', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('a'), ...base(1) }, + { kind: 'item', itemId: 'b', revision: 1, body: text('b'), ...base(2) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('a2'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['a', 'b']) + }) + + it('orders by sequence regardless of the order rows are applied in', () => { + // Live append and replay must render the same list, so the fold cannot lean + // on the order it happens to be handed rows in. + const state = fold([ + { kind: 'item', itemId: 'later', revision: 1, body: text('later'), ...base(9) }, + { kind: 'item', itemId: 'earlier', revision: 1, body: text('earlier'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['earlier', 'later']) + }) + + it('orders by sequence even when the observed timestamp runs backwards', () => { + const state = fold([ + { kind: 'item', itemId: 'late', revision: 1, body: text('late'), ...base(1), ts: 9_000 }, + { + kind: 'item', + itemId: 'recovered', + revision: 1, + body: text('recovered'), + ...base(2), + ts: 10, + recovered: true + } + ]) + const items = renderJournalState(state).items + expect(items.map((item) => item.itemId)).toEqual(['late', 'recovered']) + expect(items[1]?.recovered).toBe(true) + }) + + it('pins observedAt to creation so a revision cannot relocate the row', () => { + // Clients sort the timeline by observedAt. The provider echoing a send revises + // the submission row; if that advanced the timestamp the user's own bubble + // would sort below rows that landed while the turn was in flight. + const state = fold([ + { kind: 'item', itemId: 'send', revision: 0, body: userText('ok thanks'), ...base(1) }, + { kind: 'item', itemId: 'frame', revision: 1, body: text('warning'), ...base(2) }, + { kind: 'item', itemId: 'send', revision: 1, body: userText('ok thanks'), ...base(3) } + ]) + const items = renderJournalState(state).items + expect(items.map((item) => item.itemId)).toEqual(['send', 'frame']) + expect(items.map((item) => item.observedAt)).toEqual([base(1).ts, base(2).ts]) + // The revision still lands — only its ordering keys are ignored. + expect(items[0]?.revision).toBe(1) + }) + + it('never collapses two items that carry identical text', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('run the tests'), ...base(1) }, + { kind: 'item', itemId: 'b', revision: 1, body: text('run the tests'), ...base(2) } + ]) + expect(renderJournalState(state).items).toHaveLength(2) + }) +}) + +describe('submission and dispatch state machine', () => { + const submission: JournalRow = { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + } + + it('seeds a pending submission and an optimistic bubble', () => { + const rendered = renderJournalState(fold([submission])) + expect(rendered.submissions[0]?.dispatchState).toBe('pending') + expect(rendered.items.map((item) => item.itemId)).toEqual([agentJournalSubmissionKey('cm_1')]) + }) + + it('mints a receipt and adopts the provider item id on accept', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'codex:thread-1:turn-1:0', + reason: null, + ...base(2) + } + ]) + expect(state.receipts.get('cm_1')?.cursor).toEqual({ epoch: EPOCH, sequence: 2 }) + expect(state.submissions.get('cm_1')?.providerItemId).toBe('codex:thread-1:turn-1:0') + }) + + it('folds the provider echo into the submission bubble instead of adding a second one', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'codex:thread-1:turn-1:0', + reason: null, + ...base(2) + }, + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:0', + revision: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(3) + } + ]) + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) + // The echo updates content in place; the bubble keeps its original slot. + expect(items[0]?.sequence).toBe(1) + expect(items[0]?.revision).toBe(1) + }) + + it('adopts a provider echo that arrives before dispatch settles', () => { + const body = userText('early echo') + const state = fold([ + { + kind: 'submission', + clientMessageId: 'early-client', + payloadFingerprint: sendFingerprint(body), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body, + ...base(1) + }, + { + kind: 'item', + itemId: 'codex:thread-1:root-turn:2', + revision: 1, + body, + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'early-client', + state: 'accepted', + providerItemId: 'codex:thread-1:predicted-turn:0', + reason: null, + ...base(3) + } + ]) + + expect(renderJournalState(state).items).toMatchObject([ + { itemId: agentJournalSubmissionKey('early-client'), revision: 1, sequence: 1 } + ]) + }) + + it.each([5, 10])( + 'reconciles %i rapid sends across an interleaved cancel when Codex reuses the root turn', + (count) => { + const rows: JournalRow[] = [] + for (let index = 0; index < count; index += 1) { + const body = userText(`RAPID_${index + 1}`) + rows.push( + { + kind: 'submission', + clientMessageId: `client-${index}`, + payloadFingerprint: sendFingerprint(body), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body, + ...base(rows.length + 1) + }, + { + kind: 'dispatch', + clientMessageId: `client-${index}`, + state: 'accepted', + providerItemId: `codex:thread-1:predicted-turn-${index}:0`, + reason: null, + ...base(rows.length + 2) + } + ) + } + rows.push({ + kind: 'item', + itemId: 'orca:cancel-between-sends', + revision: 1, + body: { kind: 'status', text: 'Cancelled an earlier turn.' }, + ...base(rows.length + 1) + }) + for (let index = 0; index < count; index += 1) { + rows.push({ + kind: 'item', + itemId: `codex:thread-1:root-turn:${index}`, + revision: 1, + body: userText(`RAPID_${index + 1}`), + ...base(rows.length + 1) + }) + } + + const messages = renderJournalState(fold(rows)).items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + expect(messages).toHaveLength(count) + expect(messages.map((item) => item.itemId)).toEqual( + Array.from({ length: count }, (_, index) => agentJournalSubmissionKey(`client-${index}`)) + ) + } + ) + + it('treats rejected as terminal', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'not_delivered', + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'unknown', + providerItemId: null, + reason: 'late', + ...base(3) + } + ]) + expect(state.submissions.get('cm_1')?.dispatchState).toBe('rejected') + expect(state.submissions.get('cm_1')?.reason).toBe('not_delivered') + }) + + it('lets an unknown submission settle later', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'unknown', + providerItemId: null, + reason: 'host_restarted_before_acknowledgement', + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'p1', + reason: null, + ...base(3) + } + ]) + expect(state.submissions.get('cm_1')?.dispatchState).toBe('accepted') + expect(state.receipts.get('cm_1')).toBeTruthy() + }) + + it('ignores a dispatch for a submission this epoch never saw', () => { + const state = fold([ + { + kind: 'dispatch', + clientMessageId: 'ghost', + state: 'accepted', + providerItemId: 'p', + reason: null, + ...base(1) + } + ]) + expect(state.submissions.size).toBe(0) + expect(state.receipts.size).toBe(0) + }) +}) + +describe('blob retention', () => { + it('reports the digests live rows still reference', () => { + const state = fold([ + { + kind: 'item', + itemId: 'tool', + revision: 1, + body: { + kind: 'tool-call', + name: 'bash', + input: {}, + state: 'completed', + output: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true } + }, + ...base(1) + }, + { + kind: 'item', + itemId: 'inline', + revision: 1, + body: { + kind: 'tool-call', + name: 'bash', + input: {}, + state: 'completed', + output: { head: 'y', byteLength: 1, digest: 'digest-b', truncated: false } + }, + ...base(2) + } + ]) + expect([...referencedBlobDigests(state)]).toEqual(['digest-a']) + }) + + it('stops referencing a digest once its item is tombstoned', () => { + const state = fold([ + { + kind: 'item', + itemId: 'tool', + revision: 1, + body: { + kind: 'diff', + path: 'a.ts', + patch: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true } + }, + ...base(1) + }, + { kind: 'tombstone', itemId: 'tool', revision: 2, ...base(2) } + ]) + expect(referencedBlobDigests(state).size).toBe(0) + }) +}) + +describe('malformed persisted item keys', () => { + it('degrades a malformed-percent item id to an opaque key instead of throwing', () => { + // A user-message body drives identity resolution through the key parser; + // pre-fix `parseAgentJournalItemKey('%')` threw `URIError: URI malformed`. + const state = fold([ + { kind: 'item', itemId: '%', revision: 1, body: userText('hi'), ...base(1) } + ]) + expect(renderJournalState(state).items[0]?.itemId).toBe('%') + }) +}) + +describe('bounded item-key collisions', () => { + it('keeps an oversized turn and its raw digest-form mimic as separate items', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }) + const oversizedKey = keyFor(oversizedTurnId) + const mimicKey = keyFor(digestFormMimic) + + const state = fold([ + { kind: 'item', itemId: oversizedKey, revision: 1, body: text('oversized'), ...base(1) }, + { kind: 'item', itemId: mimicKey, revision: 1, body: text('mimic'), ...base(2) } + ]) + + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual([ + oversizedKey, + mimicKey + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts new file mode 100644 index 00000000000..85e93676752 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -0,0 +1,257 @@ +// THE reducer. One implementation folds rows into the render model, and both +// the live append path and replay call it — a live-only shortcut is how a +// reconnect starts disagreeing with the screen it replaced. +// +// Rules: highest revision wins, a tombstone removes, a late lower revision is +// dropped rather than resurrecting stale content, and ordering is by the +// sequence of the row that CREATED an item (a later revision updates the body, +// it does not move the bubble). + +import type { + AgentJournalAcceptanceReceipt, + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSnapshot, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { JournalRow } from './journal-row-schema' + +export type JournalReducerState = { + sessionId: string + epoch: string + lastSequence: number + /** Lowest sequence still individually replayable; rows below it were compacted. */ + oldestSequence: number + highestFence: number + items: Map + /** Revision of a removed item, so a late lower revision cannot resurrect it. */ + tombstones: Map + submissions: Map + receipts: Map + /** Provider item id → the submission slot that adopted it. Stops an accepted + * echo from appending a second copy of the user's own message. */ + aliases: Map +} + +export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { + return { + sessionId, + epoch, + lastSequence: 0, + oldestSequence: 1, + highestFence: 0, + items: new Map(), + tombstones: new Map(), + submissions: new Map(), + receipts: new Map(), + aliases: new Map() + } +} + +export function applyJournalRow(state: JournalReducerState, row: JournalRow): void { + state.lastSequence = Math.max(state.lastSequence, row.seq) + state.highestFence = Math.max(state.highestFence, row.fence) + if (row.kind === 'epoch') { + return + } + if (row.kind === 'item') { + const itemId = resolveJournalItemId(state, row.itemId, row.body) + upsertItem(state, itemId, row.revision, { + itemId, + revision: row.revision, + body: row.body, + sequence: row.seq, + observedAt: row.ts, + ...(row.recovered ? { recovered: row.recovered } : {}) + }) + return + } + if (row.kind === 'tombstone') { + removeItem(state, resolveItemId(state, row.itemId), row.revision) + return + } + if (row.kind === 'submission') { + applySubmission(state, row) + return + } + applyDispatch(state, row) +} + +export function resolveJournalItemId( + state: JournalReducerState, + itemId: string, + body?: AgentJournalRenderItem['body'] +): string { + const aliased = state.aliases.get(itemId) + if (aliased) { + return aliased + } + const identity = parseAgentJournalItemKey(itemId) + if ( + !body || + body.kind !== 'message' || + body.role !== 'user' || + !identity || + identity.provider === 'orca' + ) { + return itemId + } + const fingerprint = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: state.sessionId, + fields: { body } + }) + // Exact payload plus queue order preserves repeated identical sends one-for-one. + const submission = [...state.submissions.values()] + .sort((left, right) => left.submittedAt - right.submittedAt) + .find((candidate) => { + if (candidate.dispatchState === 'rejected' || candidate.payloadFingerprint !== fingerprint) { + return false + } + return state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 + }) + if (!submission) { + return itemId + } + const submissionId = agentJournalSubmissionKey(submission.clientMessageId) + state.aliases.set(itemId, submissionId) + return submissionId +} + +function resolveItemId(state: JournalReducerState, itemId: string): string { + return state.aliases.get(itemId) ?? itemId +} + +function upsertItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + if (!existing) { + state.items.set(itemId, next) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + state.items.set(itemId, { ...next, sequence: existing.sequence, observedAt: existing.observedAt }) + state.tombstones.delete(itemId) +} + +function removeItem(state: JournalReducerState, itemId: string, revision: number): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) +} + +function applySubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + upsertItem(state, itemId, 0, { + itemId, + revision: 0, + body: row.body, + sequence: row.seq, + observedAt: row.ts + }) +} + +function applyDispatch( + state: JournalReducerState, + row: Extract +): void { + const submission = state.submissions.get(row.clientMessageId) + if (!submission) { + return + } + // `rejected` is terminal; a late `unknown` must not reopen a settled answer. + if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { + return + } + submission.dispatchState = row.state + submission.providerItemId = row.providerItemId + submission.reason = row.reason + submission.resolvedAt = row.ts + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +/** Project the folded state into the client-facing snapshot. */ +export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { + // Sequence is the sole ordering key; map insertion order is not, because a + // re-created item re-enters the map after the items that followed it. + const items = [...state.items.values()].sort((a, b) => a.sequence - b.sequence) + return { + sessionId: state.sessionId, + cursor: { epoch: state.epoch, sequence: state.lastSequence }, + items, + submissions: [...state.submissions.values()].sort((a, b) => a.submittedAt - b.submittedAt) + } +} + +/** Blob digests one body points at. A retained row can outlive its render item + * (a tombstone drops the item), so compaction reads rows through this too. */ +export function blobDigestsInBody(body: AgentJournalItemBody, into: Set): void { + if (body.kind === 'tool-call' && body.output?.truncated) { + into.add(body.output.digest) + } + if (body.kind === 'diff' && body.patch.truncated) { + into.add(body.patch.digest) + } + if (body.kind === 'status' && body.providerFrame?.payload.truncated) { + into.add(body.providerFrame.payload.digest) + } +} + +/** Digests referenced by live rows, so compaction knows which blobs to keep. */ +export function referencedBlobDigests(state: JournalReducerState): Set { + const digests = new Set() + for (const item of state.items.values()) { + blobDigestsInBody(item.body, digests) + } + return digests +} diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts new file mode 100644 index 00000000000..89a96465187 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -0,0 +1,168 @@ +import type { + AgentJournalDispatchState, + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import type { + JournalDispatchRow, + JournalItemRow, + JournalSubmissionRow, + JournalTombstoneRow +} from './journal-row-schema' +import type { ResolveDispatchInput } from './journal-store-contracts' + +type RowBuilder = (seq: number, ts: number) => T + +export function journalItemRowBuilder( + state: () => JournalReducerState, + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + options: { fence: number; observedAt?: number; recovered?: true } +): RowBuilder { + return (seq, ts) => + buildJournalItemRow({ + state: state(), + identity, + body, + seq, + fence: options.fence, + ts: options.observedAt ?? ts, + recovered: options.recovered + }) +} + +export function journalTombstoneRowBuilder( + state: () => JournalReducerState, + itemId: string, + fence: number +): RowBuilder { + return (seq, ts) => buildJournalTombstoneRow({ state: state(), itemId, seq, fence, ts }) +} + +export function journalSubmissionRowBuilder( + state: () => JournalReducerState, + providerHandle: AgentSessionProviderHandle, + input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + fence: number + } +): RowBuilder { + return (seq, ts) => + buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts }) +} + +export function journalDispatchRowBuilder( + state: () => JournalReducerState, + input: ResolveDispatchInput +): RowBuilder { + const providerItemId = + input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null + return (seq, ts) => + buildJournalDispatchRow({ + state: state(), + clientMessageId: input.clientMessageId, + dispatchState: input.state, + providerItemId, + reason: input.state === 'accepted' ? null : (input.reason ?? null), + seq, + fence: input.fence, + ts, + recovered: input.recovered + }) +} + +export function journalRowBase( + epoch: string, + seq: number, + fence: number, + ts: number +): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch, seq, fence, ts } +} + +export function buildJournalItemRow(input: { + state: JournalReducerState + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + seq: number + fence: number + ts: number + recovered?: true +}): JournalItemRow { + const itemId = agentJournalItemKey(input.identity) + const resolved = input.state.aliases.get(itemId) ?? itemId + const revision = (input.state.items.get(resolved)?.revision ?? 0) + 1 + return { + kind: 'item', + itemId, + revision, + body: input.body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + } +} + +export function buildJournalTombstoneRow(input: { + state: JournalReducerState + itemId: string + seq: number + fence: number + ts: number +}): JournalTombstoneRow { + const resolved = input.state.aliases.get(input.itemId) ?? input.itemId + return { + kind: 'tombstone', + itemId: input.itemId, + revision: (input.state.items.get(resolved)?.revision ?? 0) + 1, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalSubmissionRow(input: { + state: JournalReducerState + clientMessageId: string + payloadFingerprint: string + providerHandle: AgentSessionProviderHandle + body: AgentJournalMessageItem + seq: number + fence: number + ts: number +}): JournalSubmissionRow { + return { + kind: 'submission', + clientMessageId: input.clientMessageId, + payloadFingerprint: input.payloadFingerprint, + providerHandle: input.providerHandle, + body: input.body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalDispatchRow(input: { + state: JournalReducerState + clientMessageId: string + dispatchState: Exclude + providerItemId: string | null + reason: string | null + seq: number + fence: number + ts: number + recovered?: true +}): JournalDispatchRow { + return { + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.dispatchState, + providerItemId: input.providerItemId, + reason: input.reason, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts new file mode 100644 index 00000000000..5b685a1282a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import { parseJournalRow } from './journal-row-schema' + +const BASE = { v: 1, epoch: 'epoch-1', seq: 1, fence: 1, ts: 1 } + +function parse(row: Record): boolean { + return parseJournalRow(JSON.stringify(row)).ok +} + +describe('journal row validation', () => { + it('accepts every fully-formed row shape this build writes', () => { + expect( + parse({ + ...BASE, + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 't' } + }) + ).toBe(true) + expect( + parse({ + ...BASE, + kind: 'item', + itemId: 'i-1', + revision: 1, + body: { kind: 'status', text: 'x' } + }) + ).toBe(true) + expect(parse({ ...BASE, kind: 'tombstone', itemId: 'i-1', revision: 2 })).toBe(true) + expect( + parse({ + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'message', role: 'user', blocks: [] } + }) + ).toBe(true) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'accepted', + providerItemId: 'codex:t:turn:0', + reason: null + }) + ).toBe(true) + }) + + it('rejects a dispatch row missing its state or with mistyped fields', () => { + expect(parse({ ...BASE, kind: 'dispatch', clientMessageId: 'm-1' })).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 7, + providerItemId: null, + reason: null + }) + ).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'accepted', + providerItemId: 7, + reason: null + }) + ).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'rejected', + providerItemId: null, + reason: 7 + }) + ).toBe(false) + }) + + it('rejects a submission row without its fingerprint, handle, or message body', () => { + const submission = { + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'message', role: 'user', blocks: [] } + } + expect(parse({ ...submission, payloadFingerprint: undefined as never })).toBe(false) + expect(parse({ ...submission, providerHandle: 'codex' })).toBe(false) + expect(parse({ ...submission, body: 'hi' })).toBe(false) + }) + + it('rejects an item row whose body is not a kinded object', () => { + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1 })).toBe(false) + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body: 'text' })).toBe(false) + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body: {} })).toBe(false) + }) + + it('rejects an epoch row without a provider handle', () => { + expect(parse({ ...BASE, kind: 'epoch', reason: 'session_created' })).toBe(false) + }) + + it('rejects JSON-valid nested body corruption that would throw during render', () => { + const item = (body: unknown) => ({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body }) + // A resolved question's options are mapped by the projection; null throws there. + expect( + parse( + item({ + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }) + ) + ).toBe(false) + // Prompt surfaces read `resolution.state` before anything else. + expect( + parse(item({ kind: 'question', question: 'Deploy?', options: [], resolution: null })) + ).toBe(false) + expect(parse(item({ kind: 'message', role: 'user', blocks: 'not-blocks' }))).toBe(false) + expect(parse(item({ kind: 'diff', path: 'a.ts', patch: { head: 'x' } }))).toBe(false) + expect( + parse( + item({ kind: 'approval', title: 't', detail: null, options: [{ id: 1 }], resolution: null }) + ) + ).toBe(false) + // `turnLifecycle.turnId` is read whenever the value is truthy. + expect(parse(item({ kind: 'status', text: 'x', turnLifecycle: true }))).toBe(false) + }) + + it('rejects a submission row whose body is not a message item', () => { + expect( + parse({ + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'status', text: 'not a message' } + }) + ).toBe(false) + }) + + it('keeps forward compatibility for open string fields and unknown block types', () => { + const item = (body: unknown) => ({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body }) + // Renderers select known block types by equality and skip the rest. + expect( + parse(item({ kind: 'message', role: 'user', blocks: [{ type: 'future-block', data: 1 }] })) + ).toBe(true) + // Role and tool-call state are type-checked, never enum-checked. + expect( + parse(item({ kind: 'message', role: 'narrator', blocks: [{ type: 'text', text: 'hi' }] })) + ).toBe(true) + expect(parse(item({ kind: 'tool-call', name: 'Read', input: {}, state: 'paused' }))).toBe(true) + expect( + parse( + item({ + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + resolution: { + state: 'deferred', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + }, + futureField: 'ignored' + }) + ) + ).toBe(true) + }) + + it('keeps forward compatibility for new dispatch states without a version bump', () => { + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'some-future-state', + providerItemId: null, + reason: null + }) + ).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts new file mode 100644 index 00000000000..5b1bb2fb415 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -0,0 +1,200 @@ +// Persisted journal row shapes plus read-time upcasting. +// +// The journal is append-only, so migration is upcasting on read and never an +// in-place rewrite. A row whose version this build does not understand is +// UNREADABLE, not skippable: the caller must degrade to read-only rather than +// render a partial timeline or compact past a row it cannot interpret. + +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalDispatchState, + type AgentJournalItemBody, + type AgentJournalMessageItem, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody +} from '../../../shared/agent-session-journal-schemas' + +type JournalRowBase = { + /** Schema version of THIS row. */ + v: number + epoch: string + seq: number + /** Runtime fence held by the writer that appended the row. */ + fence: number + /** Observed (provider or host) timestamp. Ordering is by `seq`, not by this. */ + ts: number + /** Set when crash reconciliation appended the row after the fact. */ + recovered?: true +} + +/** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ +export type JournalEpochRow = JournalRowBase & { + kind: 'epoch' + reason: AgentJournalEpochReason + providerHandle: AgentSessionProviderHandle +} + +export const AGENT_JOURNAL_EPOCH_REASONS = [ + 'session_created', + 'legacy_import', + 'corruption', + 'unreconcilable_prefix', + 'handle_forked', + 'schema_unreadable' +] as const +export type AgentJournalEpochReason = (typeof AGENT_JOURNAL_EPOCH_REASONS)[number] + +export type JournalItemRow = JournalRowBase & { + kind: 'item' + itemId: string + revision: number + body: AgentJournalItemBody +} + +export type JournalTombstoneRow = JournalRowBase & { + kind: 'tombstone' + itemId: string + revision: number +} + +/** The write-ahead row. Durable BEFORE the adapter dispatches anything; it + * doubles as the optimistic user bubble so an accepted echo has a slot to + * reconcile into instead of appending a second copy. */ +export type JournalSubmissionRow = JournalRowBase & { + kind: 'submission' + clientMessageId: string + payloadFingerprint: string + providerHandle: AgentSessionProviderHandle + body: AgentJournalMessageItem +} + +export type JournalDispatchRow = JournalRowBase & { + kind: 'dispatch' + clientMessageId: string + state: Exclude + /** Provider item identity adopted on accept. */ + providerItemId: string | null + reason: string | null +} + +export type JournalRow = + | JournalEpochRow + | JournalItemRow + | JournalTombstoneRow + | JournalSubmissionRow + | JournalDispatchRow + +export type JournalRowParse = + | { ok: true; row: JournalRow } + /** Malformed JSON or a shape this build rejects outright. */ + | { ok: false; unreadable: false } + /** A future schema version. The host must not write or compact this journal. */ + | { ok: false; unreadable: true } + +const ROW_KINDS = new Set(['epoch', 'item', 'tombstone', 'submission', 'dispatch']) + +export function serializeJournalRow(row: JournalRow): string { + return JSON.stringify(row) +} + +/** + * Parse one persisted line. Older versions are upcast; newer versions are + * reported as unreadable so the caller fails closed. + */ +export function parseJournalRow(line: string): JournalRowParse { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return { ok: false, unreadable: false } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, unreadable: false } + } + const record = parsed as Record + const version = typeof record.v === 'number' ? record.v : null + if (version === null || !Number.isInteger(version) || version < 1) { + return { ok: false, unreadable: false } + } + if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + return { ok: false, unreadable: true } + } + const upcast = upcastRow(record, version) + return isJournalRow(upcast) ? { ok: true, row: upcast } : { ok: false, unreadable: false } +} + +/** Read-time upcast chain. Each step raises a row exactly one version. */ +function upcastRow(record: Record, version: number): Record { + let current = record + let at = version + while (at < AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + // No upcasters yet — v1 is the first shipped schema. New cases go here. + current = { ...current, v: at + 1 } + at += 1 + } + return current +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Open field values are type-checked, never enum-checked: a future build + * adding a dispatch state or handle kind must bump the row version, but this + * build should not misread a same-version row as malformed over a wider enum. + * Render BODIES are the exception and validate against the canonical deep + * schema — their nested shapes are dereferenced unguarded all the way to the + * rendered surface, so a JSON-valid corruption must fail here, not there. */ +function isJournalRow(record: Record): record is JournalRow { + if (typeof record.kind !== 'string' || !ROW_KINDS.has(record.kind)) { + return false + } + if ( + typeof record.epoch !== 'string' || + !record.epoch || + !Number.isInteger(record.seq) || + (record.seq as number) < 1 || + !Number.isInteger(record.fence) || + typeof record.ts !== 'number' + ) { + return false + } + if (record.kind === 'item') { + return ( + typeof record.itemId === 'string' && + Number.isInteger(record.revision) && + isAdmissibleAgentJournalItemBody(record.body) + ) + } + if (record.kind === 'tombstone') { + return typeof record.itemId === 'string' && Number.isInteger(record.revision) + } + if (record.kind === 'submission') { + return ( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) && + isAdmissibleAgentJournalMessageBody(record.body) + ) + } + if (record.kind === 'dispatch') { + return ( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string') + ) + } + return typeof record.reason === 'string' && isPlainObject(record.providerHandle) +} + +/** Approximate on-disk cost of a row, used for the per-session size bound. */ +export function journalRowByteLength(row: JournalRow): number { + return Buffer.byteLength(serializeJournalRow(row), 'utf8') + 1 +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts new file mode 100644 index 00000000000..4a864315c40 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -0,0 +1,42 @@ +import type { + AgentJournalCursor, + AgentJournalItemIdentity, + AgentJournalResetReason, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalCompactionPolicy } from './journal-compaction' +import type { JournalLoad } from './journal-open' +import type { JournalPayloadLimits } from './journal-payload-bounds' +import type { JournalRow } from './journal-row-schema' + +export type AgentSessionJournalOptions = { + identity: AgentSessionJournalIdentity + journalDir: string + limits?: JournalPayloadLimits + compaction?: JournalCompactionPolicy + /** Compact as the tail grows. Defaults on: without it the log never sheds. */ + autoCompact?: boolean + now?: () => number + mintEpoch?: () => string + /** A caller that already loaded the journal can avoid reading the same files again. */ + loaded?: JournalLoad | null +} + +export type JournalReadSince = + | { ok: true; rows: JournalRow[]; cursor: AgentJournalCursor } + | { ok: false; reset: AgentJournalResetReason } + +export type ResolveDispatchInput = { + clientMessageId: string + fence: number + recovered?: true +} & ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected' | 'unknown'; reason?: string | null } +) + +export type JournalAppendResult = { + cursor: AgentJournalCursor + itemId: string + revision: number +} diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts new file mode 100644 index 00000000000..6d850b64193 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -0,0 +1,756 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' +import { readJournalBlob } from './journal-blob-store' +import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE } from './journal-log-file' +import { loadJournal } from './journal-open' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from './journal-payload-bounds' +import { journalDirectoryFor, journalPathSegment } from './journal-paths' +import { + AgentSessionJournalError, + openAgentSessionJournal, + type AgentSessionJournal +} from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(value: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +async function open(overrides: Partial[0]> = {}) { + return openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('sequences', () => { + it('assigns a contiguous sequence with no gaps or reuse under concurrent appends', async () => { + const journal = await open() + const results = await Promise.all( + Array.from({ length: 25 }, (_unused, index) => + journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + ) + ) + const sequences = results.map((result) => result.cursor.sequence) + expect(new Set(sequences).size).toBe(25) + expect(sequences.slice().sort((a, b) => a - b)).toEqual( + Array.from({ length: 25 }, (_unused, index) => index + 2) + ) + }) + + it('serializes revisions of one item so the last write wins deterministically', async () => { + const journal = await open() + const results = await Promise.all([ + journal.appendItem(item(0), body('a'), { fence: 1 }), + journal.appendItem(item(0), body('b'), { fence: 1 }), + journal.appendItem(item(0), body('c'), { fence: 1 }) + ]) + expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) + expect(journal.snapshot().items).toHaveLength(1) + expect(journal.snapshot().items[0]?.revision).toBe(3) + }) + + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const identityFor = (turnId: string): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId, + ordinal: 0 + }) + const oversizedIdentity = identityFor(oversizedTurnId) + const mimicIdentity = identityFor(digestFormMimic) + const journal = await open() + + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + expect(oversized.itemId).not.toBe(mimic.itemId) + expect([oversized.revision, mimic.revision]).toEqual([1, 1]) + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('oversized'), + body('mimic') + ]) + + await reopened.appendTombstone(oversizedIdentity, { fence: 1 }) + const afterTombstoneReopen = await open() + expect(afterTombstoneReopen.snapshot().items.map((entry) => entry.body)).toEqual([ + body('mimic') + ]) + }) +}) + +describe('fences', () => { + it('rejects an append from a writer behind the journal', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( + AgentSessionJournalError + ) + }) + + it('keeps accepting appends after a rejected one', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) + await journal.appendItem(item(2), body('c'), { fence: 7 }) + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) + }) +}) + +describe('replay', () => { + it('adopts a caller-provided load without reading the journal files again', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const loaded = await loadJournal(root, IDENTITY.sessionId) + expect(loaded).not.toBeNull() + + await rm(join(root, JOURNAL_LOG_FILE), { force: true }) + await rm(join(root, JOURNAL_SNAPSHOT_FILE), { force: true }) + + const reopened = await open({ loaded }) + expect(reopened.snapshot()).toEqual(journal.snapshot()) + }) + + it('reopens to the same render model the live writer held', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendTombstone(item(1), { fence: 1 }) + const live = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(live) + }) + + it('serves a resume from a cursor and refuses one from a stale epoch', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('b'), { fence: 1 }) + + const resumed = journal.readSince(cursor) + expect(resumed.ok && resumed.rows).toHaveLength(1) + + await journal.rollEpoch('handle_forked', 2) + expect(journal.readSince(cursor)).toEqual({ ok: false, reset: 'epoch_changed' }) + }) + + it('rebuilds from a clean epoch after a rollover', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.rollEpoch('unreconcilable_prefix', 2) + expect(journal.snapshot().items).toHaveLength(0) + + const reopened = await open() + expect(reopened.epoch).toBe(journal.epoch) + expect(reopened.snapshot().items).toHaveLength(0) + }) + + it('preserves the intact prefix and quarantines a corrupt suffix', async () => { + const journal = await open() + for (let index = 0; index < 4; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const before = journal.epoch + const logPath = join(root, JOURNAL_LOG_FILE) + const lines = (await readFile(logPath, 'utf-8')).split('\n').filter(Boolean) + await writeFile(logPath, `${[...lines.slice(0, 2), ...lines.slice(3)].join('\n')}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.epoch).toBe(before) + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0')]) + const files = await readdir(root) + expect(files.some((name) => name.startsWith('quarantine-'))).toBe(true) + }) +}) + +describe('automatic compaction', () => { + // Production passes no policy and never called compact(), so the log only + // ever grew — until the size bound refused every append for good. + it('compacts on append once the retention window has rows to shed', async () => { + const policy = { minTailRows: 2, retainTailMs: 0 } + const journal = await open({ compaction: policy }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBeGreaterThan(0) + // The log sheds instead of growing with every append (7 = epoch row + 6). + const log = await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8') + expect(log.trim().split('\n').length).toBeLessThan(7) + // Nothing is lost: the folded prefix is served from the snapshot. + expect(journal.snapshot().items).toHaveLength(6) + }) + + it('does not rewrite the log while every row is inside the retention window', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 60_000 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBe(0) + }) + + it('can be turned off explicitly', async () => { + const journal = await open({ + autoCompact: false, + compaction: { minTailRows: 2, retainTailMs: 0 } + }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBe(0) + }) + + it('refuses an append when a tail shorter than the row floor cannot make room', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 900 }, + // The tail never reaches the floor, so honouring it would shed nothing. + compaction: { minTailRows: 512, retainTailMs: 10_000 } + }) + let rejected = 0 + for (let index = 0; index < 20; index += 1) { + try { + await journal.appendItem(item(index), body('x'.repeat(96)), { fence: 1 }) + } catch (error) { + expect(error).toMatchObject({ code: 'journal_bound_exceeded' }) + rejected += 1 + } + } + expect(rejected).toBeGreaterThan(0) + }) + + it('refuses once the retained snapshot itself reaches the session bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 10_000 }, + compaction: { minTailRows: 10, retainTailMs: 2 * 60 * 60 * 1000 } + }) + let rejected = 0 + for (let index = 0; index < 30; index += 1) { + try { + await journal.appendItem(item(index), body('x'.repeat(128)), { fence: 1 }) + } catch (error) { + expect(error).toMatchObject({ code: 'journal_bound_exceeded' }) + rejected += 1 + } + } + + expect(rejected).toBeGreaterThan(0) + expect(journal.snapshot().items.length).toBeLessThan(30) + }) + + it('keeps the newest rows resumable while shedding under budget pressure', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 10_000 }, + compaction: { minTailRows: 10, retainTailMs: 2 * 60 * 60 * 1000 } + }) + for (let index = 0; index < 30; index += 1) { + await journal.appendItem(item(index), body('x'.repeat(64)), { fence: 1 }) + } + + // The window yields oldest-first, never wholesale: the latest append is + // still in the log, so a client resuming from it does not reload. + const log = (await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8')).trim().split('\n') + expect(log.length).toBeGreaterThan(0) + expect(log.at(-1)).toContain('"seq"') + }) +}) + +describe('compaction and retention', () => { + it('preserves the highest fence across compaction and reopen', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await journal.compact() + const reopened = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await expect(reopened.appendItem(item(1), body('stale'), { fence: 6 })).rejects.toMatchObject({ + code: 'journal_stale_fence' + }) + }) + + it('preserves tombstones across compaction and reopen', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendTombstone(item(0), { fence: 1 }) + await journal.compact() + const reopened = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await reopened.appendItem(item(0), body('stale'), { fence: 1 }) + expect(reopened.snapshot().items).toHaveLength(0) + }) + + it('folds the prefix into the snapshot and keeps serving the retained tail', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const rendered = journal.snapshot() + const tip = journal.cursor() + await journal.compact() + + expect(journal.snapshot()).toEqual(rendered) + expect(journal.readSince({ epoch: tip.epoch, sequence: 1 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + const nearTip = journal.readSince({ epoch: tip.epoch, sequence: tip.sequence - 1 }) + expect(nearTip.ok && nearTip.rows).toHaveLength(1) + + const reopened = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + expect(reopened.snapshot()).toEqual(rendered) + expect(reopened.compactionBoundary).toBe(tip.sequence) + }) + + it('publishes the snapshot and its tail as one write, so a crash before the log rewrite loses nothing', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 5; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const rendered = journal.snapshot() + const logBefore = await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8') + await journal.compact() + const persistedSnapshot = JSON.parse( + await readFile(join(root, JOURNAL_SNAPSHOT_FILE), 'utf-8') + ) as { tail: unknown[] } + expect(persistedSnapshot.tail).toHaveLength(2) + // Simulate the crash: the snapshot landed, the truncation did not. + await writeFile(join(root, JOURNAL_LOG_FILE), logBefore, 'utf-8') + + const reopened = await open() + expect(reopened.snapshot()).toEqual(rendered) + expect(reopened.snapshot().items).toHaveLength(5) + }) + + it('prunes blobs no live row references and keeps the ones that survive', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + const kept = boundPayload('k'.repeat(64), { + ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, + inlineHeadBytes: 8 + }) + const dropped = boundPayload('d'.repeat(64), { + ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, + inlineHeadBytes: 8 + }) + const { putJournalBlob } = await import('./journal-blob-store') + await putJournalBlob(root, kept.digest, 'k'.repeat(64)) + await putJournalBlob(root, dropped.digest, 'd'.repeat(64)) + await journal.appendItem( + item(0), + { kind: 'tool-call', name: 'bash', input: {}, state: 'completed', output: kept }, + { fence: 1 } + ) + await journal.compact() + + expect(await readJournalBlob(root, kept.digest)).toBe('k'.repeat(64)) + expect(await readJournalBlob(root, dropped.digest)).toBeNull() + }) + + it('refuses a blob name that is not a bare digest, on either slash', async () => { + const { putJournalBlob } = await import('./journal-blob-store') + // A corrupt or crafted row must not steer a read or a write out of the store. + for (const name of ['../../escape', '..\\..\\escape', 'nested/name', 'NOTHEX']) { + expect(await readJournalBlob(root, name)).toBeNull() + await expect(putJournalBlob(root, name, 'payload')).rejects.toThrow('sha256 digest') + } + }) +}) + +describe('bounds', () => { + it('marks a clipped payload instead of dropping bytes silently', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 16 } + const bounded = boundPayload('x'.repeat(4_096), limits) + expect(bounded.truncated).toBe(true) + expect(bounded.head).toHaveLength(16) + expect(bounded.byteLength).toBe(4_096) + expect(boundInlineText('x'.repeat(4_096), limits).text).toContain('output truncated') + }) + + it('never splits a multi-byte character across the bound', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 4 } + // Each character is three bytes, so a naive slice would land mid-sequence. + const bounded = boundPayload('日本語テスト', limits) + expect(bounded.head).toBe('日') + expect(Buffer.byteLength(bounded.head, 'utf8')).toBeLessThanOrEqual(4) + }) + + it('leaves a payload inside the bound untouched', () => { + const bounded = boundPayload('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS) + expect(bounded.truncated).toBe(false) + expect(bounded.head).toBe('small') + expect(boundInlineText('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS).text).toBe('small') + }) + + it('refuses a single row larger than the per-session size bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 400 } + }) + // Shedding the whole tail still cannot make room, so the bound holds. + await expect( + journal.appendItem(item(0), body('x'.repeat(4_096)), { fence: 1 }) + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + }) + + it('refuses an append past the per-session size bound when compaction is off', async () => { + const journal = await open({ + autoCompact: false, + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 400 } + }) + await expect( + (async () => { + for (let index = 0; index < 50; index += 1) { + await journal.appendItem(item(index), body('x'.repeat(64)), { fence: 1 }) + } + })() + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + }) + + it('refuses an append past the per-window rate bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxAppendsPerWindow: 3, appendWindowMs: 60_000 } + }) + await expect( + (async () => { + for (let index = 0; index < 10; index += 1) { + await journal.appendItem(item(index), body('x'), { fence: 1 }) + } + })() + ).rejects.toMatchObject({ code: 'journal_rate_exceeded' }) + }) +}) + +describe('schema', () => { + it('quarantines an invalid compacted snapshot without replacing its tail', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + await journal.compact() + const epoch = journal.epoch + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const logPath = join(root, JOURNAL_LOG_FILE) + const invalidSnapshot = '{"folded history":' + await writeFile(snapshotPath, invalidSnapshot, 'utf-8') + const retainedTail = await readFile(logPath, 'utf-8') + expect(retainedTail).not.toContain('"kind":"epoch"') + + const reopened = await open() + expect(reopened.epoch).toBe(epoch) + expect(await readFile(logPath, 'utf-8')).toBe(retainedTail) + const quarantined = (await readdir(root)).find((name) => + name.startsWith('quarantine-snapshot-') + ) + expect(quarantined).toBeDefined() + expect(await readFile(join(root, quarantined!), 'utf-8')).toBe(invalidSnapshot) + }) + + it('degrades to read-only on a row from a newer build, without skipping or deleting it', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const future = JSON.stringify({ + v: 99, + kind: 'item', + epoch: journal.epoch, + seq: 99, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer host' } + }) + const before = await readFile(logPath, 'utf-8') + await writeFile(logPath, `${before}${future}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(true) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + await expect(reopened.compact()).rejects.toMatchObject({ code: 'journal_read_only' }) + expect(reopened.readSince({ epoch: reopened.epoch, sequence: 0 })).toEqual({ + ok: false, + reset: 'schema_unreadable' + }) + // The unreadable row is still on disk, and nothing was compacted past it. + expect(await readFile(logPath, 'utf-8')).toContain('"v":99') + }) + + it('skips a malformed line without giving up the journal, and discloses the skip', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}{not json\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(false) + const items = reopened.snapshot().items + // The surviving row is untouched… + expect(items.some((entry) => entry.body.kind === 'message')).toBe(true) + // …and the skip is visible in the timeline instead of silently swallowed. + expect( + items.some( + (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read') + ) + ).toBe(true) + }) + + it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}{not json\n`, 'utf-8') + + await open() + const reopened = await open() + expect( + reopened + .snapshot() + .items.filter( + (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read') + ) + ).toHaveLength(1) + }) + + it('repairs a torn tail before acknowledging the next append', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const intact = await readFile(logPath, 'utf-8') + await writeFile(logPath, intact.slice(0, -1), 'utf-8') + + await journal.appendItem(item(1), body('b'), { fence: 1 }) + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('b')]) + }) + + // Transcripts are full of emoji and CJK, so the repair's file offsets must be + // bytes: string indices would truncate mid-character and corrupt the prefix. + it('repairs a torn tail whose rows contain multi-byte characters', async () => { + const journal = await open() + await journal.appendItem(item(0), body('안녕하세요 🌊 café'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const intact = await readFile(logPath) + // Kill mid-row: keep the complete first row plus a fragment of the second. + const torn = Buffer.concat([intact, Buffer.from('{"seq":2,"kind":"it', 'utf-8')]) + await writeFile(logPath, torn) + + await journal.appendItem(item(1), body('b'), { fence: 1 }) + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('안녕하세요 🌊 café'), + body('b') + ]) + }) + + it('degrades to read-only when the snapshot comes from a newer schema', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(true) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + }) + + it('preserves a future-version snapshot with an unknown body kind in place instead of quarantining it', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + // The version advances because bodies changed: a valid newer snapshot + // carries kinds this build cannot parse and must stay unreadable in place. + snapshot.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 1, + observedAt: 1_000 + } + ] + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + + const reopened = await open() + const entries = await readdir(root) + expect(entries.some((name) => name.startsWith('quarantine-'))).toBe(false) + expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(true) + expect(reopened.isReadOnly).toBe(true) + expect(reopened.snapshot().items).toHaveLength(0) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + }) + + it('keeps the future-version snapshot bytes when the schema escape hatch rolls the epoch', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + snapshot.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 1, + observedAt: 1_000 + } + ] + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + const reopened = await open() + // Still live in place before the explicit escape hatch runs. + expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(false) + + await reopened.rollEpoch('schema_unreadable', 2) + expect(reopened.isReadOnly).toBe(false) + const quarantine = (await readdir(root)).find((name) => name.startsWith('quarantine-')) + expect(quarantine).toBeDefined() + expect(await readFile(join(root, quarantine!), 'utf-8')).toContain('future-render-kind') + }) + + it('reopens a log holding an admitted malformed-percent item id without throwing', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + // `parseJournalRow` admits any string itemId, so replay must degrade a + // malformed percent key to an opaque id instead of throwing URIError. + const malformedKeyRow = JSON.stringify({ + v: 1, + epoch: journal.epoch, + seq: journal.cursor().sequence + 1, + fence: 1, + ts: 1, + kind: 'item', + itemId: '%', + revision: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}${malformedKeyRow}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(false) + expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) + }) + + it('allows the explicit schema-unreadable epoch escape hatch while preserving the old files', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + const reopened = await open() + + await reopened.rollEpoch('schema_unreadable', 2) + expect(reopened.isReadOnly).toBe(false) + expect(reopened.snapshot().items).toHaveLength(0) + expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(true) + }) + + it('keeps the unreadable log suffix in the schema escape quarantine', async () => { + const journal = await open() + const logPath = join(root, JOURNAL_LOG_FILE) + const future = JSON.stringify({ + v: 99, + kind: 'item', + epoch: journal.epoch, + seq: 2, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'preserve these bytes' } + }) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}${future}\n`, 'utf-8') + const reopened = await open() + + await reopened.rollEpoch('schema_unreadable', 2) + const quarantine = (await readdir(root)).find((name) => name.startsWith('quarantine-')) + expect(quarantine).toBeDefined() + expect(await readFile(join(root, quarantine!), 'utf-8')).toContain('preserve these bytes') + }) +}) + +describe('journal location', () => { + it('keys by workspace and session id rather than by a path in the working tree', () => { + const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' }) + expect(dir).toBe( + join( + '/state', + 'agent-session-journal', + journalPathSegment('ws/1'), + journalPathSegment('sess:2') + ) + ) + expect(dir).not.toContain('ws/1') + }) + + it('separates two sessions in one workspace', () => { + const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' }) + const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' }) + expect(a).not.toBe(b) + }) +}) + +describe('on-disk layout', () => { + it('writes the log and snapshot beside each other', async () => { + const journal: AgentSessionJournal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await expect(readFile(join(root, JOURNAL_LOG_FILE), 'utf-8')).resolves.toContain( + '"kind":"item"' + ) + await expect(readFile(join(root, JOURNAL_SNAPSHOT_FILE), 'utf-8')).resolves.toContain('"epoch"') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts new file mode 100644 index 00000000000..52a47ae2561 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -0,0 +1,361 @@ +// Append-only journal store for one agent session. + +import { randomUUID } from 'node:crypto' +import type { + AgentJournalAcceptanceReceipt, + AgentJournalCursor, + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentJournalSnapshot, + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + budgetPressurePolicy, + compactJournal, + DEFAULT_JOURNAL_COMPACTION_POLICY, + journalTailCanShedRows, + journalTailIsReadyToCompact, + type JournalCompactionPolicy +} from './journal-compaction' +import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' +import { readJournalSince } from './journal-cursor' +import { publishNewEpoch } from './journal-epoch-rollover' +import { appendJournalRows, ensureJournalDir } from './journal-log-file' +import { + malformedRowsDisclosure, + quarantineCorruptSuffix, + quarantineUnreadableSchema +} from './journal-corruption-quarantine' +import { loadJournal, type JournalLoad } from './journal-open' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { markJournalPendingSubmissionsUnknown } from './journal-pending-submission-recovery' +import { + applyJournalRow, + createJournalReducerState, + referencedBlobDigests, + renderJournalState, + resolveJournalItemId, + type JournalReducerState +} from './journal-reducer' +import { + journalDispatchRowBuilder, + journalItemRowBuilder, + journalSubmissionRowBuilder, + journalTombstoneRowBuilder +} from './journal-row-builders' +import type { + AgentSessionJournalOptions, + JournalAppendResult, + JournalReadSince, + ResolveDispatchInput +} from './journal-store-contracts' +import { + journalRowByteLength, + type AgentJournalEpochReason, + type JournalRow +} from './journal-row-schema' +import { + assertJournalFence, + assertJournalWritable, + JournalAppendBudget +} from './journal-write-guards' + +export { AgentSessionJournalError } from './journal-write-guards' + +export async function openAgentSessionJournal( + options: AgentSessionJournalOptions +): Promise { + const journal = new AgentSessionJournal(options) + await journal.open() + return journal +} + +export class AgentSessionJournal { + private readonly identity: AgentSessionJournalIdentity + private readonly journalDir: string + private readonly budget: JournalAppendBudget + private readonly compaction: JournalCompactionPolicy + private readonly autoCompact: boolean + private readonly now: () => number + private readonly mintEpoch: () => string + private readonly loaded: JournalLoad | null | undefined + + private state: JournalReducerState + private tailRows: JournalRow[] = [] + private compactedThrough = 0 + private sizeBytes = 0 + private readOnly = false + private malformedRows = 0 + /** Serializes sequence assignment with the durable write behind it. */ + private writes: Promise = Promise.resolve() + + constructor(options: AgentSessionJournalOptions) { + this.identity = options.identity + this.journalDir = options.journalDir + this.budget = new JournalAppendBudget( + options.identity.sessionId, + options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS + ) + this.autoCompact = options.autoCompact ?? true + this.compaction = options.compaction ?? DEFAULT_JOURNAL_COMPACTION_POLICY + this.now = options.now ?? (() => Date.now()) + this.mintEpoch = options.mintEpoch ?? randomUUID + this.loaded = options.loaded + this.state = createJournalReducerState(options.identity.sessionId, '') + } + + get isReadOnly(): boolean { + return this.readOnly + } + + get epoch(): string { + return this.state.epoch + } + + get directory(): string { + return this.journalDir + } + + /** Highest sequence folded into the snapshot; rows at or below it are no + * longer individually replayable. */ + get compactionBoundary(): number { + return this.compactedThrough + } + + async open(): Promise { + await ensureJournalDir(this.journalDir) + const loaded = + this.loaded !== undefined + ? this.loaded + : await loadJournal(this.journalDir, this.identity.sessionId) + if (!loaded) { + await this.startEpoch('session_created', 0) + return + } + this.adoptLoadedJournal(loaded) + if (loaded.corrupt && !loaded.readOnly) { + // The epoch stays put: no intact history is discarded to recover. + await quarantineCorruptSuffix(this.journalDir, this.tailRows, loaded.quarantineRemainder) + } + if (this.malformedRows > 0 && !this.readOnly) { + const disclosure = malformedRowsDisclosure(this.malformedRows) + await this.appendItem(disclosure.identity, disclosure.body, { + fence: this.state.highestFence + }) + } + } + + cursor = (): AgentJournalCursor => ({ + epoch: this.state.epoch, + sequence: this.state.lastSequence + }) + + snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] + + pendingSubmissions = (): AgentJournalSubmission[] => + this.submissions().filter((entry) => entry.dispatchState === 'pending') + + /** The durable answer to "did my send land?" — a reconnecting client asking + * again gets this instead of re-sending. */ + receiptFor(clientMessageId: string): AgentJournalAcceptanceReceipt | null { + return this.state.receipts.get(clientMessageId) ?? null + } + + canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) + + referencedBlobDigests(): Set { + return referencedBlobDigests(this.state) + } + + readSince(cursor: AgentJournalCursor): JournalReadSince { + return readJournalSince( + { state: this.state, tailRows: this.tailRows, readOnly: this.readOnly }, + cursor, + () => this.cursor() + ) + } + + /** Upsert by stable identity. The revision is assigned here so a caller + * cannot accidentally publish a revision the reducer will drop. */ + appendItem( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + options: { fence: number; observedAt?: number; recovered?: true } = { fence: 0 } + ): Promise { + const itemId = agentJournalItemKey(identity) + return this.enqueue(journalItemRowBuilder(() => this.state, identity, body, options)).then( + (row) => ({ + cursor: { epoch: row.epoch, sequence: row.seq }, + itemId, + revision: (row as Extract).revision + }) + ) + } + + appendTombstone( + identity: AgentJournalItemIdentity, + options: { fence: number } + ): Promise { + const itemId = agentJournalItemKey(identity) + return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then( + (row) => ({ epoch: row.epoch, sequence: row.seq }) + ) + } + + /** + * Write-ahead submission row. It is durable before the caller dispatches + * anything, and it doubles as the optimistic user bubble so an accepted echo + * reconciles into an existing slot instead of appending a second copy. + */ + appendSubmission(input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + fence: number + }): Promise { + return this.enqueue( + journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input) + ).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + } + + /** + * Advance a submission to exactly one of accepted / rejected / unknown. + * + * Accepting REQUIRES the provider identity rather than a free-form id: the + * adopted key is what the provider's echo will upsert into, so a mismatched + * string here would silently give the user a second copy of their own message. + */ + resolveDispatch(input: ResolveDispatchInput): Promise { + return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) + } + + /** On restart every `pending` submission becomes `unknown` before the session + * accepts a writer. Orca never re-sends on the user's behalf. */ + async markPendingSubmissionsUnknown(fence: number): Promise { + return markJournalPendingSubmissionsUnknown(this, fence) + } + + async compact( + now = this.now(), + policy: JournalCompactionPolicy = this.compaction + ): Promise { + assertJournalWritable(this.readOnly, this.identity.sessionId) + const result = await compactJournal({ + journalDir: this.journalDir, + state: this.state, + tailRows: this.tailRows, + policy, + now, + maxSessionBytes: this.budget.maxSessionBytes + }) + this.tailRows = result.tailRows + this.compactedThrough = result.compactedThrough + this.state.oldestSequence = result.oldestSequence + this.sizeBytes = this.tailRows.reduce((total, row) => total + journalRowByteLength(row), 0) + } + + /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, + * and an unreadable schema. It invalidates every cursor; clients reload. */ + async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise { + if (reason !== 'schema_unreadable') { + assertJournalWritable(this.readOnly, this.identity.sessionId) + } else if (this.readOnly) { + await quarantineUnreadableSchema(this.journalDir) + } + await this.startEpoch(reason, fence) + this.readOnly = false + return this.cursor() + } + + replaceEpochItems( + reason: AgentJournalEpochReason, + fence: number, + items: readonly JournalReplacementItem[] + ): Promise { + const run = this.writes.then(async () => { + assertJournalWritable(this.readOnly, this.identity.sessionId) + assertJournalFence(fence, this.state.highestFence) + await replaceJournalEpoch({ + journalDir: this.journalDir, + identity: this.identity, + reason, + fence, + items, + budget: this.budget.fork(), + compaction: this.compaction, + now: this.now, + mintEpoch: this.mintEpoch, + onSnapshotPublished: (loaded) => this.adoptLoadedJournal(loaded) + }) + return this.cursor() + }) + this.writes = run.catch(() => undefined) + return run + } + + private async startEpoch(reason: AgentJournalEpochReason, fence: number): Promise { + this.adoptLoadedJournal( + await publishNewEpoch({ + journalDir: this.journalDir, + sessionId: this.identity.sessionId, + providerHandle: this.identity.providerHandle, + epoch: this.mintEpoch(), + reason, + fence, + now: this.now() + }) + ) + } + + private adoptLoadedJournal(loaded: JournalLoad): void { + this.state = loaded.state + this.tailRows = loaded.tailRows + this.compactedThrough = loaded.compactedThrough + this.sizeBytes = loaded.sizeBytes + this.readOnly = loaded.readOnly + this.malformedRows = loaded.malformedRows + } + + /** + * Assign the next sequence, make the row durable, and fold it through the + * SAME reducer replay uses — all inside one serialized step, so concurrent + * callers cannot interleave and mint the same sequence. + */ + private enqueue(build: (seq: number, ts: number) => JournalRow): Promise { + const run = this.writes.then(async () => { + assertJournalWritable(this.readOnly, this.identity.sessionId) + const ts = this.now() + const row = build(this.state.lastSequence + 1, ts) + assertJournalFence(row.fence, this.state.highestFence) + const budgetCompaction = budgetPressurePolicy(this.compaction) + if ( + this.autoCompact && + this.budget.wouldExceedSize(row, this.sizeBytes) && + journalTailCanShedRows(this.tailRows, budgetCompaction, ts) + ) { + await this.compact(ts, budgetCompaction) + } + this.budget.assert(row, ts, this.sizeBytes) + await appendJournalRows(this.journalDir, [row]) + applyJournalRow(this.state, row) + this.tailRows.push(row) + this.sizeBytes += journalRowByteLength(row) + // Nothing else calls compact(), so without this the log only ever grows — + // until the size bound refuses every append for the rest of the session. + if (this.autoCompact && journalTailIsReadyToCompact(this.tailRows, this.compaction, ts)) { + await this.compact(ts) + } + return row + }) + this.writes = run.catch(() => undefined) + return run + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts new file mode 100644 index 00000000000..50a47d02bd1 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -0,0 +1,191 @@ +// Restart reconciliation for the crash boundary. +// +// A submission row is durable before dispatch, so after a crash the host knows +// what it TRIED to send but not whether the provider took it. Every surviving +// `pending` becomes `unknown` and is then matched against provider history. +// +// Matching is by identity only — an echoed client message id, else a provider +// item id the journal already adopted, else the payload fingerprint when it +// picks out exactly one unclaimed item. Never by text equality: the same +// question asked twice is two messages, and collapsing them silently loses one. +// +// Orca never re-sends on the user's behalf. An unresolved submission stays +// `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor +// failed — and the user chooses to resend or discard. + +import type { + AgentJournalItemIdentity, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' + +export type ProviderHistoryItem = { + /** The provider's own id for this item. Used to claim it at most once; the + * journal key comes from `identity`, because a provider id is not stable. */ + providerItemId: string + /** The client message id when the provider echoes one (Codex carries it on + * user messages); null for providers that drop it. */ + clientMessageId: string | null + /** Fingerprint of the submitted payload, when the caller can compute one from + * provider content. Used only to break an otherwise unique tie. */ + payloadFingerprint: string | null + identity: AgentJournalItemIdentity +} + +export type ProviderHistoryWindow = { + /** Provider items observed at or after the journal's last committed item. */ + items: readonly ProviderHistoryItem[] + /** + * The history read actually started at the journal's last committed item. A + * fork, a compacted provider log, or a truncated read makes absence + * meaningless, so a missing submission cannot be called "not delivered". + */ + boundaryConsistent: boolean + /** The provider reports a turn still running: absence proves nothing yet. */ + turnInFlight: boolean +} + +export type SubmissionReconciliation = + | { + clientMessageId: string + outcome: 'accepted' + providerItemId: string + identity: AgentJournalItemIdentity + } + | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } + | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } + +export type SubmissionRejectionReason = 'not_delivered' + +export type SubmissionUnknownReason = + | 'history_boundary_inconsistent' + | 'turn_in_flight' + | 'ambiguous_match' + +/** + * Resolve every unsettled submission against provider history. + * + * Passes run strongest-first across ALL submissions before the next begins, so + * a weak fingerprint tie can never claim an item that an echoed client message + * id would have matched exactly. Each history item is claimable once. + */ +export function reconcileSubmissions(input: { + submissions: readonly AgentJournalSubmission[] + history: ProviderHistoryWindow +}): SubmissionReconciliation[] { + const unsettled = input.submissions.filter( + (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' + ) + const claimed = new Set() + const matched = new Map() + const ambiguous = new Set() + + claimBy( + unsettled, + input.history.items, + claimed, + matched, + (submission, item) => + // A submission that already adopted a key re-matches on that key, not on the + // provider's raw id — the raw id renumbers, the identity-derived key does not. + Boolean(submission.providerItemId) && + submission.providerItemId === agentJournalItemKey(item.identity) + ) + claimBy( + unsettled, + input.history.items, + claimed, + matched, + (submission, item) => + Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId + ) + + for (const submission of unsettled) { + if (matched.has(submission.clientMessageId)) { + continue + } + const candidates = input.history.items.filter( + (item) => + !claimed.has(item.providerItemId) && + Boolean(item.payloadFingerprint) && + item.payloadFingerprint === submission.payloadFingerprint + ) + const only = candidates.length === 1 ? candidates[0] : undefined + if (only) { + claimed.add(only.providerItemId) + matched.set(submission.clientMessageId, only) + } else if (candidates.length > 1) { + // Two identical payloads and no id to tell them apart: guessing would + // either duplicate the user's message or drop one of them. + ambiguous.add(submission.clientMessageId) + } + } + + return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) +} + +function claimBy( + submissions: readonly AgentJournalSubmission[], + items: readonly ProviderHistoryItem[], + claimed: Set, + matched: Map, + matches: (submission: AgentJournalSubmission, item: ProviderHistoryItem) => boolean +): void { + for (const submission of submissions) { + if (matched.has(submission.clientMessageId)) { + continue + } + const item = items.find((candidate) => { + return !claimed.has(candidate.providerItemId) && matches(submission, candidate) + }) + if (item) { + claimed.add(item.providerItemId) + matched.set(submission.clientMessageId, item) + } + } +} + +function resolveOne( + submission: AgentJournalSubmission, + matched: Map, + ambiguous: Set, + history: ProviderHistoryWindow +): SubmissionReconciliation { + const item = matched.get(submission.clientMessageId) + if (item) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'accepted', + providerItemId: item.providerItemId, + identity: item.identity + } + } + if (ambiguous.has(submission.clientMessageId)) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'ambiguous_match' + } + } + if (!history.boundaryConsistent) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'history_boundary_inconsistent' + } + } + if (history.turnInFlight) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'turn_in_flight' + } + } + // Absent from a history we can trust the boundary of, with nothing running: + // the provider never took it. + return { + clientMessageId: submission.clientMessageId, + outcome: 'rejected', + reason: 'not_delivered' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts new file mode 100644 index 00000000000..83071595f8b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -0,0 +1,86 @@ +// Guards an append clears before it becomes durable. +// +// All four refuse loudly rather than degrade: a silent drop here is a message +// missing from the transcript with nothing to explain it. + +import type { JournalPayloadLimits } from './journal-payload-bounds' +import { journalRowByteLength, type JournalRow } from './journal-row-schema' + +export class AgentSessionJournalError extends Error { + constructor( + readonly code: + | 'journal_read_only' + | 'journal_stale_fence' + | 'journal_bound_exceeded' + | 'journal_rate_exceeded', + message: string + ) { + super(message) + this.name = 'AgentSessionJournalError' + } +} + +/** A journal written by a newer schema is readable but never writable: this + * host cannot represent rows it does not understand. */ +export function assertJournalWritable(readOnly: boolean, sessionId: string): void { + if (readOnly) { + throw new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${sessionId} uses a newer schema; this host is read-only` + ) + } +} + +/** A write from a superseded owner is rejected outright — merging it would let + * two writers share one sequence space. */ +export function assertJournalFence(fence: number, highestFence: number): void { + if (fence < highestFence) { + throw new AgentSessionJournalError( + 'journal_stale_fence', + `fence ${fence} is behind the journal's ${highestFence}` + ) + } +} + +/** Total size and append rate for one session, bounding a runaway agent. */ +export class JournalAppendBudget { + private windowStart = 0 + private appendsInWindow = 0 + + constructor( + private readonly sessionId: string, + private readonly limits: JournalPayloadLimits + ) {} + + fork(): JournalAppendBudget { + return new JournalAppendBudget(this.sessionId, this.limits) + } + + get maxSessionBytes(): number { + return this.limits.maxSessionBytes + } + + wouldExceedSize(row: JournalRow, sizeBytes: number): boolean { + return sizeBytes + journalRowByteLength(row) > this.limits.maxSessionBytes + } + + assert(row: JournalRow, ts: number, sizeBytes: number): void { + if (this.wouldExceedSize(row, sizeBytes)) { + throw new AgentSessionJournalError( + 'journal_bound_exceeded', + `agent-session journal for ${this.sessionId} reached its ${this.limits.maxSessionBytes}-byte bound` + ) + } + if (ts - this.windowStart >= this.limits.appendWindowMs) { + this.windowStart = ts + this.appendsInWindow = 0 + } + this.appendsInWindow += 1 + if (this.appendsInWindow > this.limits.maxAppendsPerWindow) { + throw new AgentSessionJournalError( + 'journal_rate_exceeded', + `agent-session journal for ${this.sessionId} exceeded ${this.limits.maxAppendsPerWindow} appends per ${this.limits.appendWindowMs}ms` + ) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts new file mode 100644 index 00000000000..47db72cb28e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_DELTA_COALESCE_MS, + createAgentSessionDeltaCoalescer +} from './agent-session-delta-coalescer' + +/** Drives the window by hand so the test asserts scheduling, not wall time. */ +function manualClock() { + const pending: { run: () => void; ms: number }[] = [] + return { + schedule: (run: () => void, ms: number) => { + const entry = { run, ms } + pending.push(entry) + return () => { + const index = pending.indexOf(entry) + if (index !== -1) { + pending.splice(index, 1) + } + } + }, + fire: () => { + const due = pending.splice(0) + for (const entry of due) { + entry.run() + } + }, + windows: () => pending.map((entry) => entry.ms), + pendingCount: () => pending.length + } +} + +function coalescer(clock: ReturnType, windowMs?: number) { + const emitted: [string, string][] = [] + const instance = createAgentSessionDeltaCoalescer({ + emit: (key, text) => emitted.push([key, text]), + schedule: clock.schedule, + ...(windowMs === undefined ? {} : { windowMs }) + }) + return { instance, emitted } +} + +describe('agent-session delta coalescer', () => { + it('folds a burst into one emit carrying the full text, on one shared window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'he') + instance.append('item-1', 'llo') + instance.append('item-2', 'world') + + expect(emitted).toEqual([]) + expect(clock.windows()).toEqual([AGENT_SESSION_DELTA_COALESCE_MS]) + + clock.fire() + expect(emitted).toEqual([ + ['item-1', 'hello'], + ['item-2', 'world'] + ]) + }) + + it('emits the accumulated snapshot again, not the increment, on the next window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'he') + clock.fire() + instance.append('item-1', 'llo') + clock.fire() + + expect(emitted).toEqual([ + ['item-1', 'he'], + ['item-1', 'hello'] + ]) + }) + + it('does not re-emit a stream with no new text', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'hi') + clock.fire() + instance.flushAll() + + expect(emitted).toEqual([['item-1', 'hi']]) + }) + + it('flushes pending text ahead of a lifecycle event and cancels the window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'partial') + instance.flushAll() + + expect(emitted).toEqual([['item-1', 'partial']]) + expect(clock.pendingCount()).toBe(0) + }) + + it('drops a forgotten stream without emitting it, because its final body already landed', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'stale') + instance.append('item-2', 'kept') + instance.forget('item-1') + clock.fire() + + expect(emitted).toEqual([['item-2', 'kept']]) + }) + + it('emits nothing after dispose, and leaves no timer behind', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'gone') + instance.dispose() + clock.fire() + + expect(emitted).toEqual([]) + expect(clock.pendingCount()).toBe(0) + }) + + it('honours an overridden window', () => { + const clock = manualClock() + const { instance } = coalescer(clock, 5) + + instance.append('item-1', 'x') + + expect(clock.windows()).toEqual([5]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts new file mode 100644 index 00000000000..6b230a2c630 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -0,0 +1,93 @@ +// Server-side coalescing for streamed assistant text. +// +// Providers emit one notification per token. Journaling each one would write a +// row and wake every subscriber per token, so a single long answer costs +// thousands of appends and thousands of stream frames on a phone. Deltas are +// therefore accumulated and flushed on a short window; the journal row is a +// SNAPSHOT of the text so far, which is also what makes dropping intermediate +// frames safe for a reconnecting client. +// +// The window applies to text only. Lifecycle — an item completing, a turn +// ending, an approval arriving — bypasses it by flushing first, so nothing can +// be journaled ahead of the text that preceded it. + +/** Long enough to fold a burst of tokens into one row, short enough that the + * text still reads as streaming. */ +export const AGENT_SESSION_DELTA_COALESCE_MS = 60 + +export type AgentSessionDeltaCoalescerDeps = { + /** Called with the FULL text accumulated for the key, not the increment. */ + emit: (key: string, text: string) => void + windowMs?: number + /** Injected by tests so a window can be driven without real time. */ + schedule?: (run: () => void, ms: number) => () => void +} + +export type AgentSessionDeltaCoalescer = { + append: (key: string, delta: string) => void + /** Emit one stream now, if it has unflushed text. */ + flush: (key: string) => void + /** Emit every stream now. The lifecycle bypass. */ + flushAll: () => void + /** Drop a stream without emitting — its authoritative body arrived, so the + * accumulated text is now the stale copy. */ + forget: (key: string) => void + dispose: () => void +} + +function defaultSchedule(run: () => void, ms: number): () => void { + const timer = setTimeout(run, ms) + timer.unref?.() + return () => clearTimeout(timer) +} + +export function createAgentSessionDeltaCoalescer( + deps: AgentSessionDeltaCoalescerDeps +): AgentSessionDeltaCoalescer { + const windowMs = deps.windowMs ?? AGENT_SESSION_DELTA_COALESCE_MS + const schedule = deps.schedule ?? defaultSchedule + const streams = new Map() + let cancelTimer: (() => void) | null = null + + const flushKey = (key: string): void => { + const stream = streams.get(key) + if (!stream?.dirty) { + return + } + stream.dirty = false + deps.emit(key, stream.text) + } + + const flushAll = (): void => { + cancelTimer?.() + cancelTimer = null + for (const key of streams.keys()) { + flushKey(key) + } + } + + return { + append: (key, delta) => { + const stream = streams.get(key) ?? { text: '', dirty: false } + stream.text += delta + stream.dirty = true + streams.set(key, stream) + // One timer for every stream: a shared deadline bounds latency the same + // way and costs one wakeup per window instead of one per stream. + cancelTimer ??= schedule(() => { + cancelTimer = null + flushAll() + }, windowMs) + }, + flush: flushKey, + flushAll, + forget: (key) => { + streams.delete(key) + }, + dispose: () => { + cancelTimer?.() + cancelTimer = null + streams.clear() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts new file mode 100644 index 00000000000..51c2835c51d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -0,0 +1,632 @@ +import { appendFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { + serializeJournalRow, + type JournalItemRow, + type JournalRow, + type JournalTombstoneRow +} from '../agent-session-journal/journal-row-schema' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +let epochs = 0 +let journal: AgentSessionJournal + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-')) + clock = 1_000 + epochs = 0 + journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => { + epochs += 1 + return `epoch-${epochs}` + } + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('resolveHistoryLimit', () => { + it('clamps rather than rejecting so a mid-scroll client keeps paging', () => { + expect(resolveHistoryLimit(undefined)).toBe(40) + expect(resolveHistoryLimit(0)).toBe(1) + expect(resolveHistoryLimit(-5)).toBe(1) + expect(resolveHistoryLimit(10_000)).toBe(AGENT_SESSION_HISTORY_MAX_LIMIT) + expect(resolveHistoryLimit(Number.NaN)).toBe(40) + }) +}) + +describe('readAgentSessionHistory', () => { + it('serves the newest page on tail and pages backward from it', async () => { + await appendItems(5) + const tail = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 2 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')]) + expect(tail.page.hasOlder).toBe(true) + expect(tail.page.hasNewer).toBe(false) + expect(tail.page.liveCursor).toEqual(journal.cursor()) + + const older = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: tail.page.window.nextCursor, + limit: 2 + }) + if (!older.ok) { + throw new Error(`expected a page, got reset ${older.reset}`) + } + expect(older.page.items.map((entry) => entry.body)).toEqual([body('item-2'), body('item-3')]) + expect(older.page.hasNewer).toBe(true) + }) + + it('catches a live reader up from its cursor and stops at the limit', async () => { + await appendItems(2) + const cursor = journal.cursor() + await appendItems(5) + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 2 + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(2) + expect(page.page.hasNewer).toBe(true) + expect(page.page.window.nextCursor.sequence).toBeGreaterThan(cursor.sequence) + }) + + it('carries tombstones in a forward catch-up page', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(0) + expect(page.page.removedItemIds).toEqual(['codex:thread-1:turn-1:1']) + }) + + it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => { + await appendItems(1) + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'after' }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('resets a cursor from a previous epoch', async () => { + await appendItems(1) + const stale = journal.cursor() + await journal.rollEpoch('legacy_import', 2) + for (const direction of ['before', 'after'] as const) { + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction, cursor: stale }) + ).toMatchObject({ ok: false, reset: 'epoch_changed' }) + } + }) + + it('resets a cursor ahead of the journal', async () => { + await appendItems(1) + const ahead = { epoch: journal.epoch, sequence: journal.cursor().sequence + 10 } + expect( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: ahead + }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('carries the submission for a message on the page', async () => { + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'a'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + const page = readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail' }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items[0]?.itemId).toBe(agentJournalSubmissionKey('msg-1')) + expect(page.page.submissions).toHaveLength(1) + expect(page.page.submissions[0]).toMatchObject({ + clientMessageId: 'msg-1', + dispatchState: 'pending' + }) + }) +}) + +describe('history page byte ceiling', () => { + // A legal user message may be 256 KiB; twenty of them serialize past the + // 4 MiB outbound channel cap, which closes the socket on overflow. + const LARGE_TEXT = 'x'.repeat(250 * 1024) + + async function appendLargeItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + } + } + + function pageOf(result: ReturnType) { + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + // The actual channel gate: the page must serialize under the outbound cap. + serializeRemoteRuntimePayload(result.page) + return result.page + } + + it('keeps a tail of legal large messages under the channel cap and still pages back to every item', async () => { + await appendLargeItems(20) + + const tail = pageOf( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) + ) + expect(tail.items.length).toBeGreaterThan(0) + expect(tail.hasOlder).toBe(true) + + const seen = tail.items.map((entry) => entry.itemId) + let cursor = tail.window.nextCursor + let hasOlder = tail.hasOlder + let guard = 0 + while (hasOlder) { + guard += 1 + expect(guard).toBeLessThan(30) + const page = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor, + limit: 40 + }) + ) + expect(page.items.length).toBeGreaterThan(0) + seen.push(...page.items.map((entry) => entry.itemId)) + cursor = page.window.nextCursor + hasOlder = page.hasOlder + } + expect(new Set(seen).size).toBe(20) + }) + + it('bounds a forward catch-up page by bytes and keeps replaying to the head', async () => { + const start = { epoch: journal.epoch, sequence: 0 } + await appendLargeItems(20) + + const first = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: start, + limit: 40 + }) + ) + expect(first.items.length).toBeGreaterThan(0) + expect(first.hasNewer).toBe(true) + + const seen = first.items.map((entry) => entry.itemId) + let cursor = first.window.nextCursor + let hasNewer = first.hasNewer + let guard = 0 + while (hasNewer) { + guard += 1 + expect(guard).toBeLessThan(30) + const page = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 40 + }) + ) + expect(page.items.length).toBeGreaterThan(0) + seen.push(...page.items.map((entry) => entry.itemId)) + cursor = page.window.nextCursor + hasNewer = page.hasNewer + } + expect(new Set(seen).size).toBe(20) + }) + + it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + + const tail = pageOf( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) + ) + expect(tail.items).toHaveLength(1) + const bodyOnPage = tail.items[0]?.body + expect(bodyOnPage?.kind).toBe('status') + expect(bodyOnPage?.kind === 'status' ? bodyOnPage.text : '').toContain('[Orca: item truncated') + }) +}) + +describe('projectJournalBatch', () => { + it('reports a hole in the row sequence as journal_gap', async () => { + await appendItems(3) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const withHole = since.rows.filter((row) => row.seq !== since.rows[1]?.seq) + expect( + projectJournalBatch({ rows: withHole, snapshot: journal.snapshot(), afterSequence: 0 }) + ).toEqual({ ok: false, reset: 'journal_gap' }) + }) + + it('publishes touched items at their current reduced state, not as a delta', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('revised'), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.items).toHaveLength(1) + expect(projected.batch.items[0]).toMatchObject({ body: body('revised'), revision: 2 }) + expect(projected.batch.cursor).toEqual(journal.cursor()) + }) + + it('lists a tombstoned item as removed', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.removedItemIds).toHaveLength(1) + expect(projected.batch.items).toHaveLength(0) + }) + + it('publishes a mismatched provider echo under its submission slot', async () => { + const message: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued follow-up' }] + } + await journal.appendSubmission({ + clientMessageId: 'client-follow-up', + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body: message } + }), + body: message, + fence: 1 + }) + const cursor = journal.cursor() + await journal.resolveDispatch({ + clientMessageId: 'client-follow-up', + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: 'thread-1', + turnId: 'predicted', + ordinal: 0 + }, + fence: 1 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, + message, + { fence: 1 } + ) + + const page = readAgentSessionHistory(journal, { + sessionId: IDENTITY.sessionId, + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toMatchObject([ + { itemId: agentJournalSubmissionKey('client-follow-up'), revision: 1 } + ]) + expect(page.page.removedItemIds).toEqual([]) + }) +}) + +/** Serialize through the actual channel gate and hand back the byte length. */ +function serializedPageBytes(value: unknown): number { + return Buffer.byteLength(serializeRemoteRuntimePayload(value), 'utf8') +} + +type RawSeedRow = + | Omit + | Omit + +/** Simulate rows admitted before identity bounding existed: written straight + * into the log, then loaded by a fresh journal instance. */ +async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { + const full = rows.map( + (row) => + ({ + ...row, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: journal.epoch, + fence: 1, + ts: tick() + }) as JournalRow + ) + await appendFile( + join(root, JOURNAL_LOG_FILE), + `${full.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + return openAgentSessionJournal({ identity: IDENTITY, journalDir: root, now: tick }) +} + +describe('pre-existing oversized identities', () => { + // The exact escape from the round-two review: a legal 5 MiB Codex turnId + // admitted before bounding, then tombstoned. Its removal id alone exceeds + // the 4 MiB outbound cap, so no page can ever carry it. + const HUGE_ITEM_ID = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + + it('answers an unfittable pre-existing removal with a bounded reset instead of an unsendable page', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') }, + { kind: 'tombstone', itemId: HUGE_ITEM_ID, revision: 2, seq: seq + 2 } + ]) + + for (const sequence of [seq, seq + 1]) { + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence }, + limit: 40 + }) + expect(result.ok).toBe(false) + if (result.ok) { + throw new Error('expected a reset') + } + expect(result.reset).toBe('cursor_compacted') + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + // The reset page replaces client state wholesale, so the removal is + // applied without carrying the id, and resuming from the live cursor + // starts past the unsendable row. + expect(result.page.items).toEqual([]) + const liveCursor = result.page.liveCursor + if (!liveCursor) { + throw new Error('expected a live cursor on the reset page') + } + expect(liveCursor.sequence).toBeGreaterThanOrEqual(seq + 2) + + const resumed = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: liveCursor, + limit: 40 + }) + expect(resumed.ok).toBe(true) + } + }) + + it('charges removal ids into the page budget and splits catch-up instead of overflowing', async () => { + const seq = journal.cursor().sequence + const removalIds = Array.from( + { length: 30 }, + (_, index) => `codex:thread-1:${'r'.repeat(250 * 1024)}:${index}` + ) + const reopened = await reopenWithRawRows( + removalIds.map((itemId, index) => ({ + kind: 'tombstone' as const, + itemId, + revision: 1, + seq: seq + 1 + index + })) + ) + + const seen = new Set() + let cursor = { epoch: reopened.epoch, sequence: seq } + let guard = 0 + while (true) { + guard += 1 + expect(guard).toBeLessThan(30) + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 40 + }) + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + for (const removed of result.page.removedItemIds) { + seen.add(removed) + } + cursor = result.page.window.nextCursor + if (!result.page.hasNewer) { + break + } + } + expect(seen).toEqual(new Set(removalIds)) + }) + + it('bounds the truncation marker id for a live oversized-id item', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') } + ]) + + const tail = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'tail', + limit: 40 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(serializedPageBytes(tail.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(tail.page.items).toHaveLength(1) + const marker = tail.page.items[0] + expect(marker?.body.kind).toBe('status') + expect(marker?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + expect(marker?.itemId.length).toBeLessThan(2048) + + const forward = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence: seq }, + limit: 40 + }) + if (!forward.ok) { + throw new Error(`expected a page, got reset ${forward.reset}`) + } + expect(serializedPageBytes(forward.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(forward.page.items[0]?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + }) +}) + +describe('identity bounding at admission', () => { + it('bounds a new oversized provider identity so its item and removal share one sendable key', async () => { + const oversized: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'T'.repeat(5 * 1024 * 1024), + ordinal: 1 + } + const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } + const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + expect(appended.itemId.length).toBeLessThan(2048) + expect(appended.itemId).toContain('~orca-oversized~') + + const beforeTombstone = journal.cursor() + await journal.appendTombstone(oversized, { fence: 1 }) + + const created = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: start, + limit: 40 + }) + if (!created.ok) { + throw new Error(`expected a page, got reset ${created.reset}`) + } + expect(serializedPageBytes(created.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(created.page.removedItemIds).toEqual([appended.itemId]) + + const removal = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: beforeTombstone, + limit: 40 + }) + if (!removal.ok) { + throw new Error(`expected a page, got reset ${removal.reset}`) + } + expect(serializedPageBytes(removal.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(removal.page.removedItemIds).toEqual([appended.itemId]) + }) + + it('keeps bounded keys deterministic and a fixed point of re-derivation', () => { + const oversized = 'x'.repeat(2 * 1024 * 1024) + const bounded = boundJournalKeyComponent(oversized) + expect(bounded).toBe(boundJournalKeyComponent(oversized)) + expect(boundJournalKeyComponent(bounded)).toBe(bounded) + expect(bounded.length).toBeLessThan(2048) + expect(boundJournalKeyComponent(`${oversized}y`)).not.toBe(bounded) + expect(boundJournalKeyComponent('short')).toBe('short') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts new file mode 100644 index 00000000000..aa79ae8ee85 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -0,0 +1,349 @@ +// Paged history over one journal. +// +// `tail` and `before` read the REDUCED timeline, so backward paging keeps +// working after compaction — the folded snapshot still holds every live item. +// `after` is the catch-up direction and must read rows instead: an item created +// early and revised late orders by its creation sequence, so an item-window +// read would silently skip that revision. Rows carry the revision, which is why +// `after` is the only direction that can answer `cursor_compacted`. + +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSnapshot, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { + AGENT_SESSION_HISTORY_DEFAULT_LIMIT, + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryDirection, + type AgentSessionHistoryPage, + type AgentSessionHistoryRequest, + type AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +/** Byte budget for one history page. Half the outbound channel cap, so the RPC + * envelope and page framing always fit beside the items: row counts alone + * cannot protect the channel — forty legal 256 KiB messages serialize past the + * 4 MiB outbound cap, and an overflow closes the client's socket on every + * reopen. Pages degrade to fewer rows instead; `hasOlder`/`hasNewer` keep the + * client paging. */ +export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2 + +/** Reserved for everything the page carries beyond its items and removal ids: + * cursors, session/epoch ids, and the RPC envelope. Charged up front so the + * content budget bounds the COMPLETE serialized result, not just the rows. */ +const HISTORY_PAGE_ENVELOPE_RESERVE_BYTES = 64 * 1024 + +const HISTORY_PAGE_CONTENT_BUDGET_BYTES = + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES - HISTORY_PAGE_ENVELOPE_RESERVE_BYTES + +/** Item bytes plus the submission the page would carry alongside it. */ +function historyEntryBytes( + item: AgentJournalRenderItem, + submissionBytes: ReadonlyMap +): number { + return Buffer.byteLength(JSON.stringify(item), 'utf8') + (submissionBytes.get(item.itemId) ?? 0) +} + +function submissionBytesByItemId( + submissions: readonly AgentJournalSubmission[] +): Map { + return new Map( + submissions.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + Buffer.byteLength(JSON.stringify(submission), 'utf8') + ]) + ) +} + +/** Visible stand-in for an item whose body alone exceeds the page budget. The + * full body stays in the journal — this bounds what ONE PAGE carries, it never + * rewrites the record. */ +function oversizedHistoryItem( + item: AgentJournalRenderItem, + byteLength: number +): AgentJournalRenderItem { + return { + ...item, + // A pre-bounding id can exceed the budget by itself; the stand-in must not + // re-inflate the page it exists to bound. Bounding is deterministic, so + // re-reads keep deduplicating on the same key. + itemId: boundJournalKeyComponent(item.itemId), + body: { + kind: 'status', + text: `[Orca: item truncated — ${byteLength} bytes exceeds the history page budget]` + } + } +} + +/** + * Keep the edge of the window nearest the requested position within the byte + * budget: `newest` for tail/backward pages, `oldest` for forward catch-up. The + * page stays contiguous, so the dropped remainder is exactly what the next page + * serves. Never empties a non-empty window — a single over-budget item degrades + * to a visible marker so the client always makes progress. + */ +function boundHistoryItemsByBytes( + items: AgentJournalRenderItem[], + keep: 'newest' | 'oldest', + submissionBytes: ReadonlyMap, + maxBytes: number +): { items: AgentJournalRenderItem[]; dropped: number } { + const ordered = keep === 'newest' ? items.toReversed() : items + const kept: AgentJournalRenderItem[] = [] + let total = 0 + for (const item of ordered) { + const bytes = historyEntryBytes(item, submissionBytes) + if (kept.length === 0 && bytes > maxBytes) { + kept.push(oversizedHistoryItem(item, bytes)) + break + } + if (total + bytes > maxBytes) { + break + } + kept.push(item) + total += bytes + } + return { + items: keep === 'newest' ? kept.toReversed() : kept, + dropped: items.length - kept.length + } +} + +/** Clamped, never rejected: a client asking for more than the host will serve + * should get a smaller page and keep paging, not an error mid-scroll. */ +export function resolveHistoryLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return AGENT_SESSION_HISTORY_DEFAULT_LIMIT + } + return Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, Math.max(1, Math.floor(limit))) +} + +export function readAgentSessionHistory( + journal: AgentSessionJournal, + request: AgentSessionHistoryRequest +): AgentSessionHistoryResult { + const snapshot = journal.snapshot() + if (journal.isReadOnly) { + return historyReset(snapshot, 'schema_unreadable') + } + const limit = resolveHistoryLimit(request.limit) + if (request.direction === 'after') { + return readForward(journal, snapshot, request.cursor, limit) + } + const cursor = request.direction === 'before' ? request.cursor : undefined + if (cursor) { + if (cursor.epoch !== snapshot.cursor.epoch) { + return historyReset(snapshot, 'epoch_changed') + } + if (cursor.sequence > snapshot.cursor.sequence) { + return historyReset(snapshot, 'cursor_ahead') + } + } + const older = cursor + ? snapshot.items.filter((item) => item.sequence < cursor.sequence) + : snapshot.items + const windowed = older.slice(Math.max(0, older.length - limit)) + const { items, dropped } = boundHistoryItemsByBytes( + windowed, + 'newest', + submissionBytesByItemId(snapshot.submissions), + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) + return { + ok: true, + page: buildPage({ + snapshot, + direction: request.direction, + items, + hasOlder: older.length > windowed.length || dropped > 0, + hasNewer: older.length < snapshot.items.length, + fallbackCursor: cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: items[0] + ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } + : undefined + }) + } +} + +export function readAgentSessionHydrationPage( + journal: AgentSessionJournal, + fence?: number +): AgentSessionHistoryPage { + return buildHydrationPage(journal.snapshot(), fence) +} + +function buildHydrationPage( + snapshot: AgentJournalSnapshot, + fence?: number +): AgentSessionHistoryPage { + const items = snapshot.items.slice(-AGENT_SESSION_HISTORY_MAX_LIMIT) + const bounded = boundHistoryItemsByBytes( + items, + 'newest', + submissionBytesByItemId(snapshot.submissions), + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) + return buildPage({ + snapshot, + direction: 'tail', + items: bounded.items, + hasOlder: snapshot.items.length > items.length || bounded.dropped > 0, + hasNewer: false, + fallbackCursor: { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: bounded.items[0] + ? { epoch: snapshot.cursor.epoch, sequence: bounded.items[0].sequence } + : undefined, + fence + }) +} + +function historyReset( + snapshot: AgentJournalSnapshot, + reset: Extract['reset'] +): AgentSessionHistoryResult { + return { + ok: false, + reset, + page: buildHydrationPage(snapshot) + } +} + +function readForward( + journal: AgentSessionJournal, + snapshot: AgentJournalSnapshot, + cursor: AgentJournalCursor | undefined, + limit: number +): AgentSessionHistoryResult { + if (!cursor) { + // Why: forward paging replays rows after a position; without one there is + // nothing to be after, and silently serving the tail would hand the client + // a page it cannot place. + return historyReset(snapshot, 'cursor_ahead') + } + const since = journal.readSince(cursor) + if (!since.ok) { + return historyReset(snapshot, since.reset) + } + const submissionBytes = submissionBytesByItemId(snapshot.submissions) + // The page cost is EVERYTHING variable it carries: items with their + // submissions AND removal ids — a legal pre-bounding tombstone id can dwarf + // every item on the page. + const pageContentBytes = ( + items: readonly AgentJournalRenderItem[], + removedItemIds: readonly string[] + ): number => + items.reduce((total, item) => total + historyEntryBytes(item, submissionBytes), 0) + + removedItemIds.reduce( + (total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1, + 0 + ) + // Rows replay forward, so the byte bound shrinks the ROW window rather than + // clipping projected items: dropping an item while advancing the cursor past + // the rows that touched it would lose that revision for good. + let rows = since.rows.slice(0, limit) + let projected = projectJournalBatch({ + rows, + snapshot, + afterSequence: cursor.sequence, + canonicalItemId: (itemId) => journal.canonicalItemId(itemId) + }) + if (!projected.ok) { + return historyReset(snapshot, projected.reset) + } + while ( + rows.length > 1 && + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) { + rows = rows.slice(0, Math.ceil(rows.length / 2)) + const shrunk = projectJournalBatch({ + rows, + snapshot, + afterSequence: cursor.sequence, + canonicalItemId: (itemId) => journal.canonicalItemId(itemId) + }) + if (!shrunk.ok) { + return historyReset(snapshot, shrunk.reset) + } + projected = shrunk + } + // One row can still touch an over-budget item; degrade it visibly. + const items = + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ? projected.batch.items.map((item) => { + const bytes = historyEntryBytes(item, submissionBytes) + return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES + ? oversizedHistoryItem(item, bytes) + : item + }) + : projected.batch.items + if (pageContentBytes(items, projected.batch.removedItemIds) > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + // A single row's semantic payload — in practice a pre-bounding oversized + // removal id — can never fit any page, and truncating a removal id would + // break the client's keying. A bounded tail replaces the client's state + // wholesale, which applies the removal without carrying the id, and the + // client resumes from the live cursor past this row. + return historyReset(snapshot, 'cursor_compacted') + } + const lastSequence = rows.at(-1)?.seq ?? cursor.sequence + return { + ok: true, + page: buildPage({ + snapshot, + direction: 'after', + items, + removedItemIds: projected.batch.removedItemIds, + // Reading after a position means there is something before it. + hasOlder: cursor.sequence > 0, + hasNewer: since.rows.length > rows.length, + fallbackCursor: cursor, + nextCursor: { epoch: cursor.epoch, sequence: lastSequence } + }) + } +} + +function buildPage(input: { + snapshot: AgentJournalSnapshot + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + removedItemIds?: string[] + hasOlder: boolean + hasNewer: boolean + fallbackCursor: AgentJournalCursor + nextCursor: AgentJournalCursor | undefined + fence?: number +}): AgentSessionHistoryPage { + const epoch = input.snapshot.cursor.epoch + const pageItemIds = new Set(input.items.map((item) => item.itemId)) + const oldest = input.items[0] + const newest = input.items.at(-1) + return { + sessionId: input.snapshot.sessionId, + epoch, + ...(input.fence !== undefined ? { fence: input.fence } : {}), + direction: input.direction, + items: input.items, + removedItemIds: input.removedItemIds ?? [], + submissions: input.snapshot.submissions.filter((submission) => + pageItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ), + window: { + oldest: oldest ? { epoch, sequence: oldest.sequence } : null, + newest: newest ? { epoch, sequence: newest.sequence } : null, + nextCursor: input.nextCursor ?? input.fallbackCursor + }, + liveCursor: input.snapshot.cursor, + hasOlder: input.hasOlder, + hasNewer: input.hasNewer + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts new file mode 100644 index 00000000000..61c811f6762 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -0,0 +1,82 @@ +// Rows appended since a cursor, projected into what a subscriber must apply. +// +// The batch carries each touched item at its CURRENT reduced state rather than +// the raw rows: a client that applies the same batch twice converges, and a +// provider echo that was adopted into a submission slot arrives under the slot +// key instead of appearing as a second copy of the user's own message. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { findSequenceGap } from '../agent-session-journal/journal-cursor' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' + +export type JournalBatchProjection = + | { ok: true; batch: AgentSessionJournalBatch } + /** A missing sequence means the tail lost a row; the subscriber reloads + * rather than rendering a timeline with a hole in it. */ + | { ok: false; reset: 'journal_gap' } + +export function projectJournalBatch(input: { + rows: readonly JournalRow[] + snapshot: AgentJournalSnapshot + /** Sequence the subscriber has already applied. */ + afterSequence: number + canonicalItemId?: (itemId: string) => string +}): JournalBatchProjection { + const gap = findSequenceGap( + input.rows.map((row) => row.seq), + input.afterSequence + 1 + ) + if (gap) { + return { ok: false, reset: 'journal_gap' } + } + const aliases = submissionAliases(input.snapshot) + const touchedItemIds = new Set() + const touchedClientMessageIds = new Set() + for (const row of input.rows) { + if (row.kind === 'item' || row.kind === 'tombstone') { + touchedItemIds.add( + input.canonicalItemId?.(row.itemId) ?? aliases.get(row.itemId) ?? row.itemId + ) + continue + } + if (row.kind === 'submission' || row.kind === 'dispatch') { + touchedClientMessageIds.add(row.clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(row.clientMessageId)) + } + } + + const live = new Map(input.snapshot.items.map((item) => [item.itemId, item])) + const items = [...touchedItemIds] + .map((itemId) => live.get(itemId)) + .filter((item) => item !== undefined) + .sort((a, b) => a.sequence - b.sequence) + return { + ok: true, + batch: { + cursor: input.snapshot.cursor, + items, + removedItemIds: [...touchedItemIds].filter((itemId) => !live.has(itemId)), + submissions: input.snapshot.submissions.filter((submission) => + touchedClientMessageIds.has(submission.clientMessageId) + ) + } + } +} + +/** + * Provider item id → the submission slot that adopted it, rebuilt from the + * snapshot's own accepted submissions. This mirrors the alias the reducer + * writes on an accepted dispatch; deriving it here keeps the projection a pure + * function of published state instead of reaching into reducer internals. + */ +function submissionAliases(snapshot: AgentJournalSnapshot): Map { + const aliases = new Map() + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + return aliases +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts new file mode 100644 index 00000000000..7fe7e6ac29a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -0,0 +1,176 @@ +// Recovery drives the real journal loader against real on-disk damage: a hole +// punched in the log, and a row stamped with a schema this host cannot read. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + providerHistoryId, + recoveryJournalDir +} from './agent-session-journal-recovery' + +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: CODEX_SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_SESSION } +} + +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + } +] + +let root: string +let journalDir: string +let historyFilePath: string + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } +} + +/** Fills a journal with `count` items and hands back the raw log lines. */ +async function seedJournal(count: number): Promise { + const journal = await openAgentSessionJournal({ identity: IDENTITY, journalDir }) + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, + { fence: 1 } + ) + } + const raw = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + return raw.split('\n').filter((line) => line.trim().length > 0) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-')) + journalDir = join(root, 'journal') + historyFilePath = join(root, 'rollout.jsonl') + await writeFile( + historyFilePath, + `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf-8' + ) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('providerHistoryId', () => { + it('uses the provider handle, never the Orca session id', () => { + expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') + expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( + 'sess-9' + ) + }) +}) + +describe('openAgentSessionJournalWithRecovery', () => { + it('opens a healthy journal untouched', async () => { + await seedJournal(2) + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toBeNull() + expect(opened.journal.snapshot().items).toHaveLength(2) + }) + + it('rebuilds a holed journal in place on a fresh epoch', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + expect(opened.journal.isReadOnly).toBe(false) + // The rebuilt timeline is the only content of its epoch — nothing from the + // damaged prefix survives into it. + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.some((text) => text.includes('item-1'))).toBe(false) + expect(texts.some((text) => text.includes('add a retry'))).toBe(true) + }) + + it('reconstructs a future-schema journal into a schema-scoped sibling, never in place', async () => { + const lines = await seedJournal(1) + await writeFile( + join(journalDir, 'log.jsonl'), + `${lines.join('\n')}\n${JSON.stringify({ v: 99, seq: 2, epoch: 'e', kind: 'item' })}\n`, + 'utf-8' + ) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ + trigger: 'schema_unreadable', + reset: 'schema_unreadable' + }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + + // The unreadable journal is left exactly as found; a newer host still owns it. + const untouched = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + expect(untouched).toContain('"v":99') + const sibling = await readFile(join(recoveryJournalDir(journalDir), 'log.jsonl'), 'utf-8') + expect(sibling).toContain('add a retry') + }) + + it('still opens the session when provider history cannot be read', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 2) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath: join(root, 'missing.jsonl') + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) + expect(opened.recovery?.error).toBeTruthy() + // A missing provider transcript must not clear the intact journal prefix. + expect(opened.journal.snapshot().items).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts new file mode 100644 index 00000000000..19b39c5bded --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -0,0 +1,114 @@ +// Journal recovery: rehydrate the timeline from provider history. +// +// Two triggers, and they need different destinations. A journal whose prefix is +// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal +// written by a NEWER schema is not writable by this host at all — rebuilding it +// in place would fork the sequence space a newer host still owns — so the +// reconstruction goes to a schema-scoped sibling directory that is only ever +// written by hosts at this version and is never merged back. + +import type { AgentType } from '../../../shared/agent-status-types' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalResetReason, + type AgentSessionJournalIdentity, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { loadJournal } from '../agent-session-journal/journal-open' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' + +export type AgentSessionJournalRecovery = { + trigger: 'journal_corrupt' | 'schema_unreadable' + /** What subscribers are told; both force a clean snapshot reload. */ + reset: AgentJournalResetReason + epoch: string + imported: number + /** Set when provider history could not be read; the intact journal prefix remains live. */ + error?: string +} + +export type AgentSessionJournalOpened = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null +} + +/** Where a reconstruction lands when the real journal cannot be written. */ +export function recoveryJournalDir(journalDir: string): string { + return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` +} + +/** The provider's own session id, which is what the transcript readers index + * by — never the Orca session id. */ +export function providerHistoryId(handle: AgentSessionProviderHandle): string { + if (handle.kind === 'codex') { + return handle.threadId + } + return handle.kind === 'claude' ? handle.sessionId : handle.value +} + +export async function openAgentSessionJournalWithRecovery(input: { + identity: AgentSessionJournalIdentity + journalDir: string + fence: number + /** Resolve directly to a transcript instead of discovering it by session id. */ + historyFilePath?: string | null +}): Promise { + const probe = await loadJournal(input.journalDir, input.identity.sessionId) + if (probe?.readOnly) { + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: recoveryJournalDir(input.journalDir) + }) + return { + journal, + recovery: await rehydrate({ ...input, journal, trigger: 'schema_unreadable' }) + } + } + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: input.journalDir + }) + if (!probe?.corrupt) { + return { journal, recovery: null } + } + // `open()` quarantines the unusable suffix; a successful import rolls once + // more so the rebuilt timeline is the only content of its epoch. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } +} + +async function rehydrate(input: { + identity: AgentSessionJournalIdentity + journal: AgentSessionJournal + fence: number + historyFilePath?: string | null + trigger: AgentSessionJournalRecovery['trigger'] +}): Promise { + const reset: AgentJournalResetReason = + input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const result = await importLegacyTranscriptIntoJournal({ + journal: input.journal, + agent: input.identity.agent satisfies AgentType, + sessionId: providerHistoryId(input.identity.providerHandle), + fence: input.fence, + ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) + }) + if (!result.ok) { + return { + trigger: input.trigger, + reset, + epoch: input.journal.epoch, + imported: 0, + error: result.error + } + } + return { + trigger: input.trigger, + reset, + epoch: result.epoch, + imported: result.imported + } +} diff --git a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts new file mode 100644 index 00000000000..4f3ef118af5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts @@ -0,0 +1,48 @@ +// SDKMessage discriminators from Claude Agent SDK 0.3.231 / Claude Code 2.1.231. +export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ + 'message:assistant', + 'message:user', + 'message:result', + 'message:system:init', + 'message:stream_event:message_start', + 'message:stream_event:message_delta', + 'message:stream_event:message_stop', + 'message:stream_event:content_block_start', + 'message:stream_event:content_block_delta', + 'message:stream_event:content_block_stop', + 'message:system:compact_boundary', + 'message:system:status', + 'message:system:api_retry', + 'message:system:control_request_progress', + 'message:system:model_refusal_fallback', + 'message:system:model_refusal_no_fallback', + 'message:system:local_command_output', + 'message:system:hook_started', + 'message:system:hook_progress', + 'message:system:hook_response', + 'message:system:plugin_install', + 'message:tool_progress', + 'message:auth_status', + 'message:system:task_notification', + 'message:system:task_started', + 'message:system:task_updated', + 'message:system:task_progress', + 'message:system:background_tasks_changed', + 'message:system:thinking_tokens', + 'message:system:session_state_changed', + 'message:system:worker_shutting_down', + 'message:system:commands_changed', + 'message:system:notification', + 'message:system:files_persisted', + 'message:tool_use_summary', + 'message:system:memory_recall', + 'message:rate_limit_event', + 'message:system:elicitation_complete', + 'message:system:permission_denied', + 'message:prompt_suggestion', + 'message:system:mirror_error', + 'message:system:informational', + 'message:conversation_reset' +] as const + +export type ClaudeStreamJsonFrameKind = (typeof CLAUDE_STREAM_JSON_FRAME_KINDS)[number] diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts new file mode 100644 index 00000000000..ad9ca66c52a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_APP_SERVER_NOTIFICATION_METHODS } from '../../codex/codex-app-server-notification-schema' +import { CLAUDE_STREAM_JSON_FRAME_KINDS } from './claude-stream-json-frame-schema' +import { + classifyProviderFrame, + isDeltaShapedProviderFrameKind, + PROVIDER_FRAME_CLASSIFICATIONS +} from './provider-frame-disposition' + +describe('provider frame classification catalog', () => { + it('classifies every pinned Codex app-server notification method', () => { + expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex)).toEqual([ + ...CODEX_APP_SERVER_NOTIFICATION_METHODS + ]) + }) + + it('classifies every pinned Claude stream-json frame kind', () => { + expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.claude)).toEqual([ + ...CLAUDE_STREAM_JSON_FRAME_KINDS + ]) + }) + + it('classifies every pinned delta kind as stream-into-item', () => { + const deltaKinds = [ + ...Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex), + ...Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.claude) + ].filter(isDeltaShapedProviderFrameKind) + + expect(deltaKinds.length).toBeGreaterThan(0) + for (const kind of deltaKinds) { + const provider = kind.startsWith('message:') ? 'claude' : 'codex' + expect(classifyProviderFrame(provider, kind, {}), kind).toBe('stream-into-item') + } + }) + + it('suppresses benign hook lifecycle and Codex progress frames', () => { + expect(classifyProviderFrame('codex', 'notification:hook/started', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:hook/completed', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:account/rateLimits/updated', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:turn/diff/updated', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('claude', 'message:system:hook_started', {})).toBe( + 'suppressed-benign' + ) + }) + + it('promotes payload failures over a benign catalog classification', () => { + expect( + classifyProviderFrame('codex', 'notification:hook/completed', { + run: { status: 'failed' } + }) + ).toBe('error-surface') + expect( + classifyProviderFrame('claude', 'message:system:hook_response', { + outcome: 'error', + stderr: 'hook failed' + }) + ).toBe('error-surface') + }) + + it('keeps unknown future frames on the substantive bounded fallback path', () => { + expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe( + 'timeline-substantive' + ) + expect(classifyProviderFrame('claude', 'message:future_event', {})).toBe('timeline-substantive') + }) + + it('structurally diverts unknown future delta kinds from generic rows', () => { + expect(classifyProviderFrame('codex', 'notification:item/newThing/outputDelta', {})).toBe( + 'stream-into-item' + ) + expect(classifyProviderFrame('claude', 'message:future_delta', {})).toBe('stream-into-item') + }) + + it('dispositions codex item-form frames, which the method catalog never matches', () => { + // `thread/compacted` is already chrome; its item form is the same event and + // must not leak `codex · item:contextCompaction` into the transcript. + expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe('status-chrome') + expect(classifyProviderFrame('codex', 'notification:thread/compacted', {})).toBe( + 'status-chrome' + ) + // An item type nobody has dispositioned still falls through visibly. + expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts new file mode 100644 index 00000000000..8d11df995a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -0,0 +1,245 @@ +import type { CodexAppServerNotificationMethod } from '../../codex/codex-app-server-notification-schema' +import type { ClaudeStreamJsonFrameKind } from './claude-stream-json-frame-schema' + +export type ProviderFrameClassification = + | 'timeline-substantive' + | 'stream-into-item' + | 'status-chrome' + | 'suppressed-benign' + | 'error-surface' + +type ProviderFrameClassificationTable = { + codex: Record + claude: Record +} + +export const PROVIDER_FRAME_CLASSIFICATIONS = { + codex: { + error: 'error-surface', + 'thread/started': 'status-chrome', + 'thread/status/changed': 'status-chrome', + 'thread/archived': 'status-chrome', + 'thread/deleted': 'status-chrome', + 'thread/unarchived': 'status-chrome', + 'thread/closed': 'status-chrome', + 'skills/changed': 'status-chrome', + 'thread/name/updated': 'status-chrome', + 'thread/goal/updated': 'status-chrome', + 'thread/goal/cleared': 'status-chrome', + 'thread/environment/connected': 'status-chrome', + 'thread/environment/disconnected': 'status-chrome', + 'thread/settings/updated': 'status-chrome', + 'thread/tokenUsage/updated': 'status-chrome', + 'turn/started': 'status-chrome', + 'hook/started': 'suppressed-benign', + 'turn/completed': 'status-chrome', + 'hook/completed': 'suppressed-benign', + 'turn/diff/updated': 'suppressed-benign', + 'turn/plan/updated': 'timeline-substantive', + 'item/started': 'timeline-substantive', + 'item/autoApprovalReview/started': 'status-chrome', + 'item/autoApprovalReview/completed': 'status-chrome', + 'item/completed': 'timeline-substantive', + 'rawResponseItem/completed': 'suppressed-benign', + 'rawResponse/completed': 'suppressed-benign', + 'item/agentMessage/delta': 'stream-into-item', + 'item/plan/delta': 'stream-into-item', + 'command/exec/outputDelta': 'stream-into-item', + 'process/outputDelta': 'stream-into-item', + 'process/exited': 'timeline-substantive', + 'item/commandExecution/outputDelta': 'stream-into-item', + 'item/commandExecution/terminalInteraction': 'stream-into-item', + 'item/fileChange/outputDelta': 'stream-into-item', + 'item/fileChange/patchUpdated': 'stream-into-item', + 'serverRequest/resolved': 'suppressed-benign', + 'item/mcpToolCall/progress': 'status-chrome', + 'mcpServer/oauthLogin/completed': 'status-chrome', + 'mcpServer/startupStatus/updated': 'status-chrome', + 'account/updated': 'status-chrome', + 'account/rateLimits/updated': 'suppressed-benign', + 'app/list/updated': 'status-chrome', + 'remoteControl/status/changed': 'status-chrome', + 'externalAgentConfig/import/progress': 'status-chrome', + 'externalAgentConfig/import/completed': 'status-chrome', + 'fs/changed': 'suppressed-benign', + 'item/reasoning/summaryTextDelta': 'stream-into-item', + 'item/reasoning/summaryPartAdded': 'stream-into-item', + 'item/reasoning/textDelta': 'stream-into-item', + 'thread/compacted': 'status-chrome', + 'model/rerouted': 'status-chrome', + 'model/verification': 'status-chrome', + 'turn/moderationMetadata': 'suppressed-benign', + 'model/safetyBuffering/updated': 'status-chrome', + warning: 'error-surface', + guardianWarning: 'error-surface', + deprecationNotice: 'error-surface', + configWarning: 'error-surface', + 'fuzzyFileSearch/sessionUpdated': 'suppressed-benign', + 'fuzzyFileSearch/sessionCompleted': 'suppressed-benign', + 'thread/realtime/started': 'status-chrome', + 'thread/realtime/itemAdded': 'timeline-substantive', + 'thread/realtime/transcript/delta': 'stream-into-item', + 'thread/realtime/transcript/done': 'timeline-substantive', + 'thread/realtime/outputAudio/delta': 'stream-into-item', + 'thread/realtime/sdp': 'suppressed-benign', + 'thread/realtime/error': 'error-surface', + 'thread/realtime/closed': 'status-chrome', + 'windows/worldWritableWarning': 'error-surface', + 'windowsSandbox/setupCompleted': 'status-chrome', + 'account/login/completed': 'status-chrome' + }, + claude: { + 'message:assistant': 'timeline-substantive', + 'message:user': 'timeline-substantive', + 'message:result': 'status-chrome', + 'message:system:init': 'status-chrome', + 'message:stream_event:message_start': 'status-chrome', + 'message:stream_event:message_delta': 'stream-into-item', + 'message:stream_event:message_stop': 'status-chrome', + 'message:stream_event:content_block_start': 'status-chrome', + 'message:stream_event:content_block_delta': 'stream-into-item', + 'message:stream_event:content_block_stop': 'status-chrome', + 'message:system:compact_boundary': 'status-chrome', + 'message:system:status': 'status-chrome', + 'message:system:api_retry': 'status-chrome', + 'message:system:control_request_progress': 'status-chrome', + 'message:system:model_refusal_fallback': 'status-chrome', + 'message:system:model_refusal_no_fallback': 'error-surface', + 'message:system:local_command_output': 'timeline-substantive', + 'message:system:hook_started': 'suppressed-benign', + 'message:system:hook_progress': 'suppressed-benign', + 'message:system:hook_response': 'suppressed-benign', + 'message:system:plugin_install': 'status-chrome', + 'message:tool_progress': 'status-chrome', + 'message:auth_status': 'status-chrome', + 'message:system:task_notification': 'status-chrome', + 'message:system:task_started': 'status-chrome', + 'message:system:task_updated': 'status-chrome', + 'message:system:task_progress': 'status-chrome', + 'message:system:background_tasks_changed': 'status-chrome', + 'message:system:thinking_tokens': 'status-chrome', + 'message:system:session_state_changed': 'status-chrome', + 'message:system:worker_shutting_down': 'status-chrome', + 'message:system:commands_changed': 'status-chrome', + 'message:system:notification': 'status-chrome', + 'message:system:files_persisted': 'status-chrome', + 'message:tool_use_summary': 'timeline-substantive', + 'message:system:memory_recall': 'timeline-substantive', + 'message:rate_limit_event': 'status-chrome', + 'message:system:elicitation_complete': 'status-chrome', + 'message:system:permission_denied': 'error-surface', + 'message:prompt_suggestion': 'status-chrome', + 'message:system:mirror_error': 'error-surface', + 'message:system:informational': 'timeline-substantive', + 'message:conversation_reset': 'status-chrome' + } +} as const satisfies ProviderFrameClassificationTable + +const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome']) +const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason']) + +function isErrorVariant(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const normalized = value.replace(/[_\s-]/g, '').toLowerCase() + return ( + normalized.startsWith('error') || normalized.startsWith('fail') || normalized === 'systemerror' + ) +} + +function hasProviderError(payload: unknown): boolean { + const pending = [payload] + const seen = new WeakSet() + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || seen.has(value)) { + continue + } + seen.add(value) + if (Array.isArray(value)) { + pending.push(...value) + continue + } + for (const [key, nested] of Object.entries(value)) { + if ((key === 'isError' || key === 'is_error') && nested === true) { + return true + } + if (key === 'success' && nested === false) { + return true + } + if (ERROR_VARIANT_KEYS.has(key) && isErrorVariant(nested)) { + return true + } + if (ERROR_VALUE_KEYS.has(key) && nested !== null && nested !== false && nested !== '') { + return true + } + pending.push(nested) + } + } + return false +} + +/** Codex thread-item types with no typed renderer, dispositioned by hand so a + * new item type cannot leak `codex · item:` into the transcript. The + * notification catalog above is keyed by METHOD and never matches these. */ +const CODEX_ITEM_CLASSIFICATIONS: Record = { + // The `thread/compacted` notification is already chrome; its item form is the + // same event and must not read as a mysterious opcode row. + contextCompaction: 'status-chrome' +} + +function notificationKind(kind: string): string { + return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind +} + +function itemKind(kind: string): string | null { + return kind.startsWith('item:') ? kind.slice('item:'.length) : null +} + +export function isDeltaShapedProviderFrameKind(kind: string): boolean { + return notificationKind(kind).toLowerCase().endsWith('delta') +} + +function catalogClassification( + provider: string, + kind: string +): ProviderFrameClassification | undefined { + if (provider === 'codex') { + const item = itemKind(kind) + if (item !== null) { + return CODEX_ITEM_CLASSIFICATIONS[item] + } + return PROVIDER_FRAME_CLASSIFICATIONS.codex[ + notificationKind(kind) as CodexAppServerNotificationMethod + ] + } + if (provider === 'claude') { + return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] + } + return undefined +} + +export function classifyProviderFrame( + provider: string, + kind: string, + payload: unknown +): ProviderFrameClassification { + // Payload failure inspection outranks the name-shape heuristic below: an + // unknown frame that reports an error must reach the user even when its + // method name happens to look like a stream delta. + if (hasProviderError(payload)) { + return 'error-surface' + } + if (isDeltaShapedProviderFrameKind(kind)) { + return 'stream-into-item' + } + if (provider === 'claude' && kind === 'message:result') { + const subtype = + typeof payload === 'object' && payload !== null + ? (payload as Record).subtype + : undefined + return subtype === 'success' ? 'status-chrome' : 'error-surface' + } + return catalogClassification(provider, kind) ?? 'timeline-substantive' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts new file mode 100644 index 00000000000..6340e12a265 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -0,0 +1,348 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'legacy-session' +const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams( + operationId: string, + expectedRuntimeFence: number | null +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'legacy-thread' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +function adapter(input: { + origin: 'created' | 'resumed' + options?: AgentSessionOptionsResult +}): StructuredAgentSessionAdapter { + return { + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `${input.origin}-link`, + handle: { provider: 'codex', threadId: 'legacy-thread' }, + origin: input.origin, + mintedAtFence: fence, + observedAt: NOW + } + })), + ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +function expectSettledAttachLease(record: AgentSessionRecord | null): void { + expect(record).not.toBeNull() + const lease = record!.lease + const durableState = lease.handoffStage ?? lease.claimStatus + expect(['live', 'released', 'recovering', 'manual-recovery']).toContain(durableState) + expect(lease.handoffStage).not.toBe('new-owner-proving') +} + +describe('structured session acquisition options', () => { + it('persists provider options before proving a resumed legacy record', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + + const created = await performAttach({ + store, + adapter: adapter({ origin: 'created' }), + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(CREATE_OPERATION, null), + now: () => NOW, + onAttached: () => {} + }) + expect(created).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + options: { approvalPolicy: 'on-request', personality: 'concise' }, + now: NOW + }) + + const resumedStore = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local' + }) + await resumedStore.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const resumed = await performAttach({ + store: resumedStore, + adapter: adapter({ + origin: 'resumed', + options: { + current: { model: 'gpt-5.6-terra', effort: 'medium' }, + models: [] + } + }), + journalRoot: root, + authority: { + spawnToken: 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: RESUME_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(RESUME_OPERATION, releasedFence), + now: () => NOW + 1, + onAttached: () => {} + }) + + expect(resumed).toMatchObject({ ok: true }) + const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(reopened.getRecord(SESSION)?.options).toEqual({ + approvalPolicy: 'on-request', + personality: 'concise', + model: 'gpt-5.6-terra', + effort: 'medium' + }) + }) + + it('releases an acquisition when provider options cannot be read', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const releaseAcquisition = vi.fn(async () => true) + const failingAdapter: StructuredAgentSessionAdapter = { + ...adapter({ origin: 'created' }), + readOptions: vi.fn(async () => { + throw new Error('model list unavailable') + }), + releaseAcquisition + } + + await expect( + performAttach({ + store, + adapter: failingAdapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(CREATE_OPERATION, null), + now: () => NOW, + onAttached: () => {} + }) + ).rejects.toThrow('model list unavailable') + expect(releaseAcquisition).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() + }) + + describe.each([ + ['adapter acquire', 'acquire'], + ['options read', 'options'], + ['identity commit', 'identity'], + ['owner proof', 'proof'], + ['journal attach', 'journal'] + ] as const)('%s failure', (_label, failurePoint) => { + it.each([ + ['proven cleanup', true], + ['unproven cleanup', false], + ['cleanup error', 'throws'] + ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { + const exitProven = cleanup === true + root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const base = adapter({ + origin: 'created', + options: { current: { model: 'gpt-5.6-terra' }, models: [] } + }) + const injected = new Error(`${failurePoint} failed`) + const acquire = vi.mocked(base.acquire) + const readOptions = vi.mocked(base.readOptions!) + if (failurePoint === 'acquire') { + acquire.mockRejectedValueOnce(injected) + } else if (failurePoint === 'options') { + readOptions.mockRejectedValueOnce(injected) + } else if (failurePoint === 'identity') { + vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) + } else if (failurePoint === 'proof') { + vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) + } else if (failurePoint === 'journal') { + acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: 'legacy-thread' }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + } + const releaseAcquisition = vi.fn(async () => { + if (cleanup === 'throws') { + throw new Error('cleanup failed') + } + return cleanup + }) + const failingAdapter = { + ...base, + acquire, + readOptions, + releaseAcquisition, + ...(failurePoint === 'journal' + ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } + : {}) + } + const perform = ( + target: AgentSessionRecordStore, + operationId: string, + fence: number | null + ) => + performAttach({ + store: target, + adapter: failingAdapter, + journalRoot: root!, + authority: { + spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(operationId, fence), + now: () => NOW, + onAttached: () => {} + }) + + await expect(perform(store, CREATE_OPERATION, null)).rejects.toThrow( + exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' + ) + + const reopened = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local' + }) + const failedRecord = reopened.getRecord(SESSION) + expectSettledAttachLease(failedRecord) + expect( + reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome + ).toMatchObject({ status: 'failed' }) + + await reopened.reconcileOnRestart({ + probe: async (record) => + exitProven || record.lease.ownerProcess === null + ? exitProven + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'owner identity was never committed' } + : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, + now: NOW + 1 + }) + + if (exitProven) { + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null + }) + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + expectSettledAttachLease(reopened.getRecord(SESSION)) + } else { + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', + handoffStage: + failurePoint === 'proof' || failurePoint === 'journal' + ? 'recovering' + : 'manual-recovery', + // The settled operation must not stay named by the lease as an in-flight transfer. + handoffOperationId: null, + reservedSpawnToken: 'spawn-a' + }) + await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts new file mode 100644 index 00000000000..5f67240c1c6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + AgentSessionAcquisitionExitUnprovenError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' + +describe('failed agent-session acquisition cleanup', () => { + it('preserves the acquisition failure after proven cleanup', async () => { + const cause = new Error('proof failed') + + await expect( + rethrowAfterAgentSessionAcquisitionCleanup( + { releaseAcquisition: vi.fn(async () => true) }, + 'session-1', + cause + ) + ).rejects.toBe(cause) + }) + + it('reports unproven exit when cleanup returns false', async () => { + await expect( + rethrowAfterAgentSessionAcquisitionCleanup( + { releaseAcquisition: vi.fn(async () => false) }, + 'session-1', + new Error('proof failed') + ) + ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + }) + + it('reports unproven exit when cleanup throws', async () => { + const error = await rethrowAfterAgentSessionAcquisitionCleanup( + { + releaseAcquisition: vi.fn(async () => { + throw new Error('cleanup failed') + }) + }, + 'session-1', + new Error('proof failed') + ).catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).toMatchObject({ cause: expect.any(AggregateError) }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts new file mode 100644 index 00000000000..b9017ddee24 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -0,0 +1,149 @@ +// What the wire needs from a provider adapter. +// +// Phase 2 implements this over the Codex app-server and the Claude Agent SDK; +// nothing here starts, resumes, or talks to a process. The wire owns the +// journal and the lease, so an adapter only has to answer "did the provider +// take this?" — and it answers `unknown` rather than guessing, because the +// journal renders that as delivery unconfirmed instead of as failure. + +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionExecutionLocation, + AgentSessionProcessIdentity +} from '../../../shared/agent-session-record' +import type { + AgentSessionOptionsResult, + AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' + +export class AgentSessionAcquisitionRefusal extends Error { + constructor( + message: string, + readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + ) { + super(message) + this.name = 'AgentSessionAcquisitionRefusal' + } +} + +export class AgentSessionAcquisitionExitUnprovenError extends Error { + constructor(cause: unknown) { + super('agent_session_acquisition_exit_unproven', { cause }) + this.name = 'AgentSessionAcquisitionExitUnprovenError' + } +} + +/** What a reservation turns into once something is actually running under it: + * the process the host can probe, and the provider handle it was minted with. */ +export type AgentSessionAcquisition = { + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink +} + +/** Acquisition validation failed before the adapter attempted to spawn. */ +export class AgentSessionPreSpawnError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionPreSpawnError' + } +} + +export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessionPreSpawnError { + return error instanceof Error && error.name === 'AgentSessionPreSpawnError' +} + +export type AgentSessionDispatchOutcome = + /** The provider owns the turn now, under this identity. */ + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected'; reason: string } + /** The call did not settle. Never re-send on the user's behalf. */ + | { state: 'unknown'; reason: string } + +export type StructuredAgentSessionAcquireInput = { + identity: AgentSessionJournalIdentity + fence: number + spawnToken: string + options?: Readonly> + /** Provider events may begin before acquisition returns. */ + events?: StructuredAgentSessionEventSink +} + +export type StructuredAgentSessionSetOptionInput = { + sessionId: string + key: string + value: string + fence: number +} + +export type StructuredAgentSessionAdapter = { + /** Provider-aware capability check for hosts that route more than one adapter. */ + supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean + /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ + supportsLocation?(location: AgentSessionExecutionLocation): boolean + /** Makes the reservation real. Called once per reservation, with the spawn + * token the lease was reserved under and the fence the handle must be minted + * at — the store rejects a link minted at any other fence. */ + acquire(input: StructuredAgentSessionAcquireInput): Promise + /** Reaps an acquired provider when the host cannot commit or prove its lease. + * Returns true only after provider child exit is proven. */ + releaseAcquisition?(input: { sessionId: string }): Promise + dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise + /** Cancels one turn, not the session: a session-wide interrupt would also kill + * a turn the client never asked to stop. */ + cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> + /** Fires the provider callback for an approval or a question. The wire calls + * this only after the durable compare-and-set won, so it runs exactly once. */ + answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise + setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> + readOptions?(input: { sessionId: string; fence: number }): Promise + /** Transcript path for journal recovery. Omit to let the existing session-file + * resolver discover it from the provider session id. */ + historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise + /** Gracefully stops the structured owner after its event stream is drained. */ + /** Returns true only after the provider child exit is proven. */ + closeSession?(sessionId: string): Promise + /** Stops a provider child for teardown without requiring a future-resume cursor. */ + disposeSession?(sessionId: string): Promise +} + +export async function rethrowAfterAgentSessionAcquisitionCleanup( + adapter: Pick, + sessionId: string, + cause: unknown +): Promise { + let released: boolean + try { + released = (await adapter.releaseAcquisition?.({ sessionId })) === true + } catch (cleanupError) { + throw new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') + ) + } + if (released) { + throw cause + } + throw new AgentSessionAcquisitionExitUnprovenError(cause) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts new file mode 100644 index 00000000000..7113be8d54b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -0,0 +1,34 @@ +// What attaching needs from the host, named explicitly. +// +// Passing the host itself would let this quietly grow new dependencies; an explicit context makes +// each one a deliberate addition and keeps the orchestration testable without constructing a host. + +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +export type StructuredAgentSessionAttachContext = { + deps: StructuredAgentSessionHostDeps + runtimeState: StructuredAgentSessionHostRuntimeState + sessions: Map + subscribers: { + reset: ( + sessionId: string, + journal: AgentSessionJournal, + reset: AgentJournalResetReason, + fence: number + ) => void + snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void + publish: (sessionId: string, journal: AgentSessionJournal) => void + } + tasks: StructuredAgentSessionTaskQueue + reconcileLeases: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts new file mode 100644 index 00000000000..f8959d5f01a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -0,0 +1,300 @@ +// The attach transition end to end: reserve the lease, make the reservation +// real, open the journal. +// +// Split out of the host so the sequence reads in one place. The host still owns +// the decisions that must not be client-supplied — the spawn token, the claim +// key, the owner probe — and passes them in. + +import { isDeepStrictEqual } from 'node:util' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAttachOrRefuse, + attachJournal, + classifyStoreFailure, + journalIdentityFor, + reserveRequestFor, + type AgentSessionAttachAuthority, + type AgentSessionAttachParams, + type AttachedJournal +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { readAgentSessionHydrationPage } from './agent-session-history-page' + +export type AttachFlowInput = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + authority: AgentSessionAttachAuthority + callerKey: string + params: AgentSessionAttachParams + now: () => number + /** Registers the opened journal and fans out to subscribers before the caller + * sees the result, so no client can send against a session the host has not + * finished publishing. */ + onAttached: (attached: AttachedJournal) => void + /** Handed to the adapter so it can journal what the provider streams. The + * host owns it and binds it to the journal inside `onAttached`. */ + eventSink?: StructuredAgentSessionEventSink + /** Stops acquisition-window events targeting the superseded journal. */ + onAcquiring?: () => Promise | void + /** Settles writes already captured by the superseded journal before opening another. */ + beforeJournalOpen?: () => Promise | void + /** Removes any partial host publication after journal attachment fails. */ + onAttachFailed?: () => void +} + +export async function performAttach( + input: AttachFlowInput +): Promise> { + const { params, store } = input + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + + let record: AgentSessionRecord + let reservedRecord: AgentSessionRecord | null = null + let replayed = false + try { + const reserved = await store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) + record = reserved.record + replayed = reserved.disposition === 'replayed' + if ( + replayed && + reserved.operationRow.outcome.status !== 'pending' && + reserved.operationRow.outcome.status !== 'succeeded' + ) { + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: reserved.operationRow.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return { ok: false, refusal: replay.refusal } + } + } + reservedRecord = record + if (!agentSessionLeaseAdmitsWriter(record.lease)) { + record = await acquireOwner(input, record) + } + } catch (error) { + const spawnToken = reservedRecord?.lease.reservedSpawnToken + if (reservedRecord && spawnToken) { + // A pre-spawn failure is its own processless proof; the settlement records the + // evidence and the failed operation in one durable transaction. + const exitProof = isAgentSessionPreSpawnError(error) + ? 'processless' + : error instanceof AgentSessionAcquisitionExitUnprovenError + ? 'unproven' + : 'exit-proven' + const outcome = + error instanceof AgentSessionAcquisitionExitUnprovenError + ? { + status: 'failed' as const, + code: 'agent_session_ownership_unknown', + message: error.message + } + : error instanceof AgentSessionAcquisitionRefusal + ? { + status: 'failed' as const, + code: error.code, + message: error.message + } + : { + status: 'failed' as const, + code: 'agent_session_operation_invalid', + message: error instanceof Error ? error.message : String(error) + } + try { + await store.settleFailedAcquisition({ + sessionId, + fence: reservedRecord.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome, + exitProof, + now: input.now() + }) + } catch (settlementError) { + throw new AggregateError( + [error, settlementError], + 'agent session acquisition failure settlement failed' + ) + } + } + if (error instanceof AgentSessionAcquisitionRefusal) { + return { ok: false, refusal: { code: error.code, message: error.message } } + } + return { + ok: false, + refusal: classifyStoreFailure( + error, + store.getRecord(sessionId)?.lease.runtimeFence ?? null, + store.getRecord(sessionId) + ) + } + } + + let attached: AttachedJournal + try { + await input.beforeJournalOpen?.() + attached = await attachJournal({ + record, + params, + journalRoot: input.journalRoot, + adapter: input.adapter + }) + input.onAttached(attached) + await store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + } catch (error) { + return settlePostAcquisitionAttachFailure(input, record, error) + } + + const fence = record.lease.runtimeFence + return { + ok: true, + replayed, + fence, + cursor: attached.journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(attached.journal, fence), + unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds + } + } +} + +async function settlePostAcquisitionAttachFailure( + input: AttachFlowInput, + record: AgentSessionRecord, + cause: unknown +): Promise { + let cleanupError: unknown = cause + let exitProof: 'exit-proven' | 'unproven' = 'unproven' + try { + await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) + } catch (error) { + cleanupError = error + exitProof = + error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' : 'exit-proven' + } + input.onAttachFailed?.() + try { + await input.store.settleFailedPostAcquisitionAttachment({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken: record.lease.reservedSpawnToken ?? '', + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + message: cause instanceof Error ? cause.message : String(cause) + }, + exitProof, + now: input.now() + }) + } catch (settlementError) { + throw new AggregateError( + [cleanupError, settlementError], + 'agent session post-acquisition attachment failure settlement failed' + ) + } + throw cleanupError +} + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + // Pre-spawn proof is single-use: this retry may create a child after the durable clear. + try { + try { + record = await input.store.setReservationProcesslessProof({ + sessionId: record.sessionId, + fence, + spawnToken, + processlessAt: null, + now: input.now() + }) + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken, + ...(record.options ? { options: record.options } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}) + }) + const options = await readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + return await input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now(), + ...(options ? { options } : {}) + }) + } catch (error) { + if (isAgentSessionPreSpawnError(error)) { + throw error + } + return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts new file mode 100644 index 00000000000..74cb78d78b9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -0,0 +1,93 @@ +// The host's attach, lifted out of the host class. +// +// Attach is the one operation that touches every collaborator the host owns — the lease +// reconciler, the recovery resolver, the event sink, the journal, the subscriber set and the task +// queue — so leaving it inline made the host grow every time any of them did. The host keeps the +// state; this owns the ordering between them. + +import { randomUUID } from 'node:crypto' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' +import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' + +export function attachStructuredAgentSession( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise> { + const sessionId = params.envelope.sessionId + const attaching = context.serialize(sessionId, async () => { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return refuseAgentSessionMutation(unreconciled) + } + await context.runtimeState.resolveRecovery(sessionId) + const eventSink = context.runtimeState.eventSinkFor(sessionId) + const attached = await performAttach({ + store: context.deps.store, + adapter: context.deps.adapter, + journalRoot: context.deps.journalRoot, + eventSink: eventSink.sink, + onAcquiring: () => eventSink.unbind(), + beforeJournalOpen: async () => { + eventSink.unbind() + await eventSink.drained() + }, + authority: { + spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: context.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: await context.runtimeState.probeOwner(sessionId), + ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), + ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) + }, + callerKey, + params, + now: () => context.now(), + onAttachFailed: () => { + context.sessions.delete(sessionId) + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + }, + onAttached: (attached) => { + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previousFence = context.sessions.get(sessionId)?.fence + context.sessions.set(sessionId, { + journal: attached.journal, + params, + fence, + hasProviderChild: true + }) + if (attached.recovery) { + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } + eventSink.bind({ + journal: attached.journal, + fence, + publish: () => context.subscribers.publish(sessionId, attached.journal) + }) + } + }) + // Why: a failed attach that left no session behind must not strand a bound sink; the runtime + // caches one per session id and would hand this same closed instance to the next attempt. + if (!attached.ok && !context.sessions.has(sessionId)) { + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + } + return attached + }) + return context.tasks.trackAttach(attaching) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts new file mode 100644 index 00000000000..cfdbf786e14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -0,0 +1,222 @@ +// Attach: reserve the session record, then open its journal. +// +// `create` and `ensure` are the same transition with a different starting +// point — a null expected fence means "no session exists yet". Both go through +// the record store's compare-and-swap, which also owns the idempotency row, so +// a retried attach replays instead of reserving a second owner. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentSessionJournalIdentity, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionLaunchArgs, + AgentSessionLaunchEnv, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + type AgentSessionJournalRecovery +} from './agent-session-journal-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' + +/** + * Everything a client may declare about the session it wants. Deliberately no + * spawn token, claim key, or owner probe: those are host observations, and a + * client that could assert "the previous owner is dead" could steal a live + * session. The host fills them in. + */ +export type AgentSessionAttachParams = { + envelope: AgentSessionMutationEnvelope + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + agent: AgentType + accountHome: AgentSessionAccountHome + runtimeKind: AgentSessionOwnerRuntimeKind + /** Omitted only for create-by-intent; the adapter proves the durable handle. */ + providerHandle?: Exclude +} + +/** Host-supplied half of the reservation. */ +export type AgentSessionAttachAuthority = { + spawnToken: string | (() => string) + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe + launchArgs?: AgentSessionLaunchArgs + launchEnv?: AgentSessionLaunchEnv +} + +/** The fields that define WHICH session this call would attach to. Deliberately + * excludes the spawn token and the probe: those differ between a first attempt + * and its retry, and a retry must replay rather than conflict. */ +export function attachFingerprintFields(params: AgentSessionAttachParams): Record { + return { + location: params.location, + provider: params.provider, + agent: params.agent, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + providerHandle: params.providerHandle, + expectedRuntimeFence: params.envelope.expectedRuntimeFence + } +} + +/** Recomputes the fingerprint the client declared and refuses a mismatch before + * anything reaches the store. */ +export function admitAttachOrRefuse( + params: AgentSessionAttachParams +): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + if (params.providerHandle && params.providerHandle.kind !== params.provider) { + return { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: `A ${params.provider} session requires a ${params.provider} provider handle.` + } + } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + return conflict ? { ok: false, refusal: conflict } : { ok: true, fingerprint } +} + +export function journalIdentityFor( + record: AgentSessionRecord, + params: AgentSessionAttachParams +): AgentSessionJournalIdentity { + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const providerHandle: AgentSessionProviderHandle = + head?.handle.provider === 'codex' + ? { kind: 'codex', threadId: head.handle.threadId } + : head?.handle.provider === 'claude' + ? { + kind: 'claude', + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid + } + : (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' }) + return { + sessionId: record.sessionId, + workspaceId: params.location.workspaceId, + hostId: params.location.executionHostId, + agent: params.agent, + providerHandle + } +} + +export type AttachedJournal = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null + /** Submissions the crash boundary settled as `unknown` on this open. */ + unconfirmedClientMessageIds: string[] +} + +/** + * Open the session's journal, recovering it when the stored one is unusable, + * and settle every submission left in flight by a previous process. Orca never + * re-sends those; they surface as delivery unconfirmed. + */ +export async function attachJournal(input: { + record: AgentSessionRecord + params: AgentSessionAttachParams + journalRoot: string + adapter: StructuredAgentSessionAdapter +}): Promise { + const identity = journalIdentityFor(input.record, input.params) + const fence = input.record.lease.runtimeFence + const historyFilePath = input.adapter.historyFilePath + ? await input.adapter.historyFilePath({ identity }) + : null + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(input.journalRoot, { + workspaceId: identity.workspaceId, + sessionId: identity.sessionId + }), + fence, + historyFilePath + }) + return { + ...opened, + unconfirmedClientMessageIds: await opened.journal.markPendingSubmissionsUnknown(fence) + } +} + +export function reserveRequestFor(input: { + sessionId: string + params: AgentSessionAttachParams + authority: AgentSessionAttachAuthority + callerKey: string + fingerprint: string + now: number +}): Parameters[0] { + const { params, authority } = input + return { + sessionId: input.sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), + ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), + runtimeKind: params.runtimeKind, + expectedFence: params.envelope.expectedRuntimeFence, + spawnToken: authority.spawnToken, + claimKeyId: authority.claimKeyId, + handoffOperationId: authority.handoffOperationId, + probe: authority.probe, + operation: { + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now + } +} + +/** The store signals refusals by throwing the refusal code. Anything not in the + * known set is a defect, not a client error, and is rethrown. */ +export function classifyStoreFailure( + error: unknown, + currentFence: number | null, + record: AgentSessionRecord | null = null +): AgentSessionWireRefusal { + const rawCode = error instanceof Error ? error.message : String(error) + if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + throw error + } + const code = rawCode as AgentSessionWireRefusalCode + return { + code, + // Why: a latched session is exactly where a bare store code strands the user. + message: + structuredAgentSessionRefusalMessage(code, record) ?? + `The session store refused this call: ${code}.`, + ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts new file mode 100644 index 00000000000..1255cc85ff3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts @@ -0,0 +1,35 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' + +export async function recoverDeadTuiHandoffStatus(input: { + store: AgentSessionRecordStore + now: () => number + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe +}): Promise { + const { expectedFence, now, probe, record, store } = input + if ( + record.lease.runtimeFence !== expectedFence || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffStage !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + return null + } + const recovered = await recoverStoredDeadTuiOwnerForHandoff(store, { + sessionId: record.sessionId, + expectedFence, + operationId: createStructuredAgentSessionOperationId(randomUUID, now()), + probe, + now: now() + }) + return idleStructuredHandoffStatus(recovered) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts new file mode 100644 index 00000000000..6407a98f7f2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget +} from './structured-agent-session-event-sink' + +const BODY: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'hi' }] +} + +function identity(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +type Recorded = { call: string; fence?: number; ordinal?: number } + +function target( + fence: number, + log: Recorded[], + failOn?: number +): StructuredAgentSessionEventTarget { + const journal = { + appendItem: vi.fn(async (id: AgentJournalItemIdentity, _body: AgentJournalItemBody) => { + const ordinal = id.provider === 'codex' ? id.ordinal : -1 + if (ordinal === failOn) { + throw new Error(`refused ${ordinal}`) + } + log.push({ call: 'appendItem', fence, ordinal }) + return { cursor: { epoch: 'e', sequence: ordinal } } + }), + appendTombstone: vi.fn(async (id: AgentJournalItemIdentity) => { + log.push({ + call: 'appendTombstone', + fence, + ordinal: id.provider === 'codex' ? id.ordinal : -1 + }) + return { epoch: 'e', sequence: 0 } + }) + } as unknown as AgentSessionJournal + return { journal, fence, publish: () => log.push({ call: 'publish', fence }) } +} + +describe('deferred structured agent-session event sink', () => { + it('buffers writes made before the journal exists and drains them in arrival order', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(1), BODY) + deferred.sink.publish() + expect(log).toEqual([]) + + deferred.bind(target(7, log)) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 7, ordinal: 0 }, + { call: 'appendItem', fence: 7, ordinal: 1 }, + { call: 'publish', fence: 7 } + ]) + }) + + it('writes at the fence bound at submission time, so a rebind cannot backdate a write', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(target(1, log)) + + deferred.sink.appendItem(identity(0), BODY) + // The re-attach that raised the fence. + deferred.bind(target(2, log)) + deferred.sink.appendItem(identity(1), BODY) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 1, ordinal: 0 }, + { call: 'appendItem', fence: 2, ordinal: 1 } + ]) + }) + + it('buffers replacement-acquisition events while unbound', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(target(1, log)) + deferred.unbind() + + deferred.sink.appendItem(identity(0), BODY) + expect(log).toEqual([]) + deferred.bind(target(2, log)) + await deferred.drained() + + expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }]) + }) + + it('drops buffered and later writes once closed, and refuses to rebind', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + deferred.sink.appendItem(identity(0), BODY) + deferred.close() + deferred.bind(target(3, log)) + deferred.sink.appendItem(identity(1), BODY) + await deferred.drained() + + expect(log).toEqual([]) + }) + + it('reports a refused append and keeps draining the rest', async () => { + const log: Recorded[] = [] + const errors: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => errors.push(error) + }) + deferred.bind(target(4, log, 0)) + + deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendTombstone(identity(1)) + await deferred.drained() + + expect(errors).toHaveLength(1) + expect((errors[0] as Error).message).toBe('refused 0') + expect(log).toEqual([{ call: 'appendTombstone', fence: 4, ordinal: 1 }]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts new file mode 100644 index 00000000000..3662da11577 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -0,0 +1,144 @@ +// Where an adapter writes the provider events it did not synchronously return. +// +// A provider starts streaming the moment its process exists, and that moment is +// INSIDE `adapter.acquire` — before the journal is open and before the host has +// registered the session. So the sink an adapter receives is deferred: writes +// queue in arrival order and drain once the journal exists. +// +// One sink lives for the session, not for one acquisition: a re-attach opens a +// NEW journal object at a NEW fence, and rebinding re-points the same sink at +// it. That keeps a single identity for the adapter to hold across a re-acquire, +// and the adapter closes the superseded child, so nothing writes behind a fence +// that has already moved. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { putJournalBlob, removeJournalBlob } from '../agent-session-journal/journal-blob-store' + +export type StructuredAgentSessionJournalBlob = { digest: string; payload: string } + +/** The only journal surface an adapter gets: append and publish, no reads. An + * adapter that could read the journal would start reconciling against it, and + * reconciliation is the wire's job, not the provider's. */ +export type StructuredAgentSessionEventSink = { + appendItem( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + blobs?: readonly StructuredAgentSessionJournalBlob[] + ): void + appendTombstone(identity: AgentJournalItemIdentity): void + /** Fan the journal out to subscribers. Cheap and idempotent. */ + publish(): void +} + +export type StructuredAgentSessionEventTarget = { + journal: AgentSessionJournal + /** Fence the sink writes at. Fixed for the life of the sink: a new fence + * means a new acquisition, which gets its own sink. */ + fence: number + publish: () => void +} + +export type DeferredStructuredAgentSessionEventSink = { + sink: StructuredAgentSessionEventSink + /** Drains everything buffered so far, in order, then writes through. Called + * again on every re-attach to re-point the sink at the new journal. */ + bind(target: StructuredAgentSessionEventTarget): void + /** Queues new provider events until a replacement journal is bound. */ + unbind(): void + /** Permanently stops the sink. Queued writes are dropped rather than landing + * in a journal the host has already let go of. */ + close(): void + /** Resolves once every write queued so far has landed. */ + drained(): Promise +} + +type SinkOperation = (target: StructuredAgentSessionEventTarget) => Promise | void + +export function createDeferredStructuredAgentSessionEventSink( + deps: { + /** A rejected append. Unset drops it: throwing here would surface inside the + * provider's notification callback and take the connection down, and the + * lease already guarantees a stale writer's rows are refused. */ + onError?: (error: unknown) => void + } = {} +): DeferredStructuredAgentSessionEventSink { + let target: StructuredAgentSessionEventTarget | null = null + let closed = false + const buffered: SinkOperation[] = [] + let chain: Promise = Promise.resolve() + + const enqueue = (operation: SinkOperation): void => { + const bound = target + chain = chain.then(async () => { + try { + await operation(bound as StructuredAgentSessionEventTarget) + } catch (error) { + deps.onError?.(error) + } + }) + } + + const submit = (operation: SinkOperation): void => { + if (closed) { + return + } + if (!target) { + buffered.push(operation) + return + } + enqueue(operation) + } + + return { + sink: { + appendItem: (identity, body: AgentJournalItemBody, blobs = []) => { + submit(async (bound) => { + const persisted: string[] = [] + try { + for (const blob of blobs) { + await putJournalBlob(bound.journal.directory, blob.digest, blob.payload) + persisted.push(blob.digest) + } + await bound.journal.appendItem(identity, body, { fence: bound.fence }) + } catch (error) { + const retained = bound.journal.referencedBlobDigests?.() ?? new Set() + for (const digest of persisted) { + if (!retained.has(digest)) { + await removeJournalBlob(bound.journal.directory, digest) + } + } + throw error + } + }) + }, + appendTombstone: (identity) => { + submit((bound) => bound.journal.appendTombstone(identity, { fence: bound.fence })) + }, + publish: () => { + submit((bound) => bound.publish()) + } + }, + bind: (next) => { + if (closed) { + return + } + target = next + const pending = buffered.splice(0) + for (const operation of pending) { + enqueue(operation) + } + }, + unbind: () => { + target = null + }, + close: () => { + closed = true + buffered.length = 0 + }, + drained: () => chain + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts new file mode 100644 index 00000000000..f34caba3593 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts @@ -0,0 +1,51 @@ +// A bound on how long teardown may take, expressed as a wrapper around the eviction steps rather +// than a change to them. +// +// The step list is ordered and abort-on-failure for reasons that have nothing to do with time, and +// a deadline must not disturb either. Wrapping each step's `run` keeps the order, and a timeout +// surfaces as that step failing — which is exactly the behavior wanted here: the rest of the +// eviction aborts, the session stays indexed, and the child stays LOADED. A stuck app-server that +// is still holding a conversation is a better outcome than one killed out from under it; the next +// close retries. + +import type { StructuredAgentSessionEvictionStep } from './structured-agent-session-eviction' + +export const STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS = 10_000 + +export class StructuredAgentSessionEvictionTimeoutError extends Error { + constructor( + readonly step: string, + readonly timeoutMs: number + ) { + super(`agent session eviction step "${step}" did not finish within ${timeoutMs}ms`) + this.name = 'StructuredAgentSessionEvictionTimeoutError' + } +} + +export function withStructuredAgentSessionEvictionDeadline( + steps: readonly StructuredAgentSessionEvictionStep[], + timeoutMs = STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS +): readonly StructuredAgentSessionEvictionStep[] { + return steps.map((step) => ({ + name: step.name, + run: async (context) => { + let timer: ReturnType | undefined + try { + await Promise.race([ + Promise.resolve(step.run(context)), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new StructuredAgentSessionEvictionTimeoutError(step.name, timeoutMs)), + timeoutMs + ) + timer.unref?.() + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } + } + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts new file mode 100644 index 00000000000..7ddae0aa48a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { + evictStructuredAgentSession, + StructuredAgentSessionEvictionError, + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + type StructuredAgentSessionEvictionContext +} from './structured-agent-session-eviction' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' + +function context(): StructuredAgentSessionEvictionContext & { order: string[] } { + const order: string[] = [] + return { + order, + sessionId: 'session-1', + eventSink: { + unbind: vi.fn(() => order.push('unbind')), + drained: vi.fn(async () => { + order.push('drained') + }), + close: vi.fn(() => order.push('close')) + } as unknown as StructuredAgentSessionEvictionContext['eventSink'], + adapter: { + closeSession: vi.fn(async () => { + order.push('closeSession') + return true + }) + } as unknown as StructuredAgentSessionEvictionContext['adapter'], + forget: vi.fn(() => order.push('forget')), + discardSink: vi.fn(() => order.push('discardSink')), + releaseLease: vi.fn(async () => { + order.push('releaseLease') + }) + } +} + +function runtimeState(): StructuredAgentSessionHostRuntimeState { + return new StructuredAgentSessionHostRuntimeState({ + store: {} as never, + adapter: {} as never + } as never) +} + +describe('structured agent session eviction', () => { + it('stops the child before it lets the sink go, then forgets the session', async () => { + const ctx = context() + await evictStructuredAgentSession(ctx) + expect(ctx.order).toEqual([ + 'closeSession', + 'drained', + 'unbind', + 'close', + 'discardSink', + 'releaseLease', + 'forget' + ]) + }) + + it('uses disposal rather than handoff close when the chat is removed', async () => { + const ctx = context() + const closeSession = vi.fn(async () => { + throw new Error('resume cursor unavailable') + }) + const disposeSession = vi.fn(async () => true) + ctx.adapter = { ...ctx.adapter, closeSession, disposeSession } + + await evictStructuredAgentSession(ctx) + + expect(disposeSession).toHaveBeenCalledWith('session-1') + expect(closeSession).not.toHaveBeenCalled() + }) + + it('names every step, so a half-finished eviction says which one failed', () => { + expect(STRUCTURED_AGENT_SESSION_EVICTION_STEPS.map((step) => step.name)).toEqual([ + 'stop-provider-child', + 'drain-published', + 'stop-publishing', + 'close-sink', + 'discard-sink', + 'release-lease', + 'forget-session' + ]) + }) +}) + +// Closing the codex child is not silent: the adapter emits its `ended` event and flushes coalesced +// text as it shuts down, and those rows are what clear the running-turn marker. If the sink is +// already closed the journal keeps claiming the agent is working, forever. +describe('rows the provider emits while closing', () => { + it('still reach the journal', async () => { + const state = runtimeState() + const sessionId = 'session-closing-rows' + const sink = state.eventSinkFor(sessionId) + const published: string[] = [] + sink.bind({ journal: {} as never, fence: 1, publish: () => published.push('final-flush') }) + + await evictStructuredAgentSession({ + sessionId, + eventSink: sink, + adapter: { + closeSession: async () => { + // What codex-structured-session-close does on its way out. + sink.sink.publish() + return true + } + } as never, + forget: () => {}, + discardSink: () => state.discardEventSink(sessionId), + releaseLease: async () => {} + }) + + expect(published).toEqual(['final-flush']) + }) +}) + +// `closeSession` returning false means the adapter could not prove the child exited and has kept +// the session indexed on purpose so a retry can reach it. +describe('a child that will not stop', () => { + it('aborts without forgetting the session, so the next close is a real retry', async () => { + const ctx = context() + ctx.adapter.closeSession = vi.fn(async () => false) + + await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ + step: 'stop-provider-child' + }) + expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.discardSink).not.toHaveBeenCalled() + expect(ctx.order).toEqual([]) + }) + + it('reports the failing step and leaves the sink usable for the retry', async () => { + const ctx = context() + ctx.adapter.closeSession = vi.fn(async () => { + throw new Error('child would not stop') + }) + + await expect(evictStructuredAgentSession(ctx)).rejects.toBeInstanceOf( + StructuredAgentSessionEvictionError + ) + expect(ctx.eventSink.close).not.toHaveBeenCalled() + expect(ctx.forget).not.toHaveBeenCalled() + }) +}) + +// The runtime caches ONE sink per session id and hands the same instance to the next attach, so an +// eviction that closes without discarding leaves a reopened chat wired to a permanently closed +// sink — it accepts every provider event and publishes none. +describe('eviction against the real sink cache', () => { + it('lets the session publish again after it is evicted and reattached', async () => { + const state = runtimeState() + const sessionId = 'session-reattach' + await evictStructuredAgentSession({ + sessionId, + eventSink: state.eventSinkFor(sessionId), + adapter: { closeSession: async () => true } as never, + forget: () => {}, + discardSink: () => state.discardEventSink(sessionId), + releaseLease: async () => {} + }) + + const published: string[] = [] + const reattached = state.eventSinkFor(sessionId) + reattached.bind({ journal: {} as never, fence: 2, publish: () => published.push('published') }) + reattached.sink.publish() + await reattached.drained() + + expect(published).toEqual(['published']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts new file mode 100644 index 00000000000..f1d73c25281 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -0,0 +1,102 @@ +// Releasing one structured session's resources. +// +// Teardown is a DATA list, not a method body, for the reason this file exists at all: the host +// tracked which sessions were live in a map, and tore them down at three unrelated call sites +// (app quit, handoff to a TUI, and error cleanup). Closing a chat was never wired to any of them, +// so a provider child outlived the chat that owned it for the whole app session. +// +// ORDER. The provider child stops FIRST. Closing it is not silent: the codex adapter emits its +// `ended` event and flushes coalesced text as part of shutting down, and those are the rows that +// clear the running-turn marker. Draining or closing the sink ahead of that drops them, which +// leaves the durable journal claiming the agent is still working — a worse outcome than the leak +// this teardown exists to fix. So: stop the child, drain what it emitted on its way out, then let +// the sink go. +// +// FAILURE. A step that fails ABORTS the rest. `closeSession` returning false means the child's +// exit was not proven and the adapter has deliberately kept the session indexed so a retry can +// reach it; forgetting it anyway stranded the process forever and reported success. Leaving the +// session in place is what makes the next close a real retry instead of a no-op. + +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' + +export type StructuredAgentSessionEvictionContext = { + sessionId: string + hasProviderChild?: boolean + eventSink: DeferredStructuredAgentSessionEventSink + adapter: StructuredAgentSessionAdapter + forget: () => void + /** Drops the cached sink so a later attach mints a fresh one. */ + discardSink: () => void + /** Hands the lease back now that this host's child is proven gone. No-ops when the record is + * not this host's to release. */ + releaseLease: () => Promise +} + +export type StructuredAgentSessionEvictionStep = { + name: string + run: (context: StructuredAgentSessionEvictionContext) => Promise | void +} + +export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSessionEvictionStep[] = + [ + { + name: 'stop-provider-child', + run: async (context) => { + if (context.hasProviderChild === false) { + return + } + // An adapter with no close has nothing to stop; anything else must PROVE the exit. + const stop = context.adapter.disposeSession ?? context.adapter.closeSession + if (stop) { + const stopped = await stop.call(context.adapter, context.sessionId) + if (stopped !== true) { + throw new Error('provider child exit was not proven') + } + } + } + }, + { name: 'drain-published', run: (context) => context.eventSink.drained() }, + { name: 'stop-publishing', run: (context) => context.eventSink.unbind() }, + { name: 'close-sink', run: (context) => context.eventSink.close() }, + // Why: the runtime caches one sink per session id and hands the SAME instance to the next + // attach. Closing without discarding leaves a reopened chat bound to a closed sink, which + // accepts every provider event and publishes none. Attach's own failure path already pairs + // these two; eviction has to as well. + { name: 'discard-sink', run: (context) => context.discardSink() }, + // Why here and not last: the durable lease still names a process this host just stopped, and a + // record left claiming a live owner is one nothing can resume — the next surface to open the + // chat would find a session it may not acquire. Placed BEFORE forget so a release that cannot + // be written aborts while the session is still indexed, which is what makes the retry real. + { name: 'release-lease', run: (context) => context.releaseLease() }, + { name: 'forget-session', run: (context) => context.forget() } + ] + +export class StructuredAgentSessionEvictionError extends Error { + constructor( + readonly step: string, + readonly sessionId: string, + override readonly cause: unknown + ) { + super(`agent session eviction failed at step "${step}" for ${sessionId}`) + this.name = 'StructuredAgentSessionEvictionError' + } +} + +/** + * Runs the eviction steps in order, stopping at the first failure. The step name travels with the + * error because the caller's only useful response is to retry, and a retry is only safe when the + * session is still indexed — which is exactly what aborting preserves. + */ +export async function evictStructuredAgentSession( + context: StructuredAgentSessionEvictionContext, + steps: readonly StructuredAgentSessionEvictionStep[] = STRUCTURED_AGENT_SESSION_EVICTION_STEPS +): Promise { + for (const step of steps) { + try { + await step.run(context) + } catch (error) { + throw new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts new file mode 100644 index 00000000000..02bf6259e20 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts @@ -0,0 +1,86 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { switchingStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function createStructuredHandoffFlowContext(input: { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + requireRecord: (sessionId: string) => AgentSessionRecord +}): StructuredAgentSessionHandoffFlowContext { + const publishStage: StructuredAgentSessionHandoffFlowContext['publishStage'] = ( + record, + direction + ) => { + input.setStatus( + record.sessionId, + switchingStructuredHandoffStatus(record, direction, input.deps.transport?.hostLabel) + ) + } + return { + ...input, + publishStage, + enterPreparing: async (record, operationId, direction) => { + const prepared = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'preparing', + handoffOperationId: operationId, + now: input.deps.now() + }) + publishStage(prepared, direction) + } + } +} + +export function requireStructuredHandoffRecord( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string +): AgentSessionRecord { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return record +} + +export async function markStructuredHandoffManualRecovery( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + _operationId: string +): Promise { + const record = context.requireRecord(sessionId) + await setStoredAgentSessionHandoffStage(context.deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + // A live TUI is still recoverable without an active operation; other records retain the + // failed operation so native/manual proof retries remain idempotent. + handoffOperationId: + record.lease.runtimeKind === 'tui' && record.lease.claimStatus === 'live' + ? null + : _operationId, + now: context.deps.now() + }) +} + +export async function stopStructuredNativeTurn( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + turnId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record || record.lease.runtimeKind !== 'native') { + return false + } + const session = deps.session(sessionId) + return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts new file mode 100644 index 00000000000..53c5903197c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -0,0 +1,216 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + rollbackStoredAgentSessionHandoffPreparation, + reserveStoredAgentSessionHandoffOwner, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' +import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' +import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' +import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + if (retry && record.lease.handoffStage === 'old-owner-stopped') { + record = await recoverNativeAfterTuiFailure(context, sessionId, operationId) + } + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-tui') + } else if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + record = context.requireRecord(sessionId) + // A kill is a request. Everything below advances the fence and hands the + // provider session to a TUI, so an unproven exit must stop here rather than + // create a second live writer on the same thread. + let nativeSuspend + try { + nativeSuspend = await deps.suspendNative(sessionId) + } catch (error) { + await rollbackPreparingNativeOwner(context, sessionId, operationId) + throw error + } + if (nativeSuspend.state === 'live') { + await rollbackPreparingNativeOwner(context, sessionId, operationId) + throw new Error('agent_session_owner_exit_unproven') + } + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + if (nativeSuspend.state === 'stopped-cleanup-failed') { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw nativeSuspend.error + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false + try { + await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + owner = await deps.transport!.launchTui({ + record, + fence: record.lease.runtimeFence, + spawnToken, + onSpawned: async (spawnedOwner) => { + owner = spawnedOwner + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: spawnedOwner.process, + now: deps.now() + }) + processIdentityCommitted = true + } + }) + if (!processIdentityCommitted) { + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: deps.now() + }) + } + record = await deps.store.proveOwner({ + sessionId, + fence: record.lease.runtimeFence, + link: owner.link, + now: deps.now() + }) + } catch (error) { + deps.stopTuiHistoryCatchup?.(sessionId) + if (!owner && error instanceof StructuredTuiLaunchCleanupError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + if (owner) { + if (!deps.transport?.stopFailedTuiLaunch) { + context.retainOwner(sessionId, owner) + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + try { + await deps.transport.stopFailedTuiLaunch(owner) + } catch (stopError) { + context.retainOwner(sessionId, owner) + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw new AggregateError( + [error, stopError], + 'The failed terminal launch could not be proven stopped.' + ) + } + } + await recoverNativeAfterTuiFailure(context, sessionId, operationId) + throw error + } + context.retainOwner(sessionId, owner) + await deps.activateTuiHistoryCatchup?.(sessionId) + context.setStatus(sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) +} + +async function rollbackPreparingNativeOwner( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +): Promise { + const { deps } = context + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } +} + +async function recoverNativeAfterTuiFailure( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +) { + const { deps } = context + let record = context.requireRecord(sessionId) + if (record.lease.handoffStage === 'new-owner-proving') { + record = await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + try { + return await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + if (error instanceof AgentSessionAcquisitionExitUnprovenError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts new file mode 100644 index 00000000000..56cbe4cdc53 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts @@ -0,0 +1,34 @@ +import { evictAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export async function closeRetainedTuiOwner(input: { + sessionId: string + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + requireRecord: (sessionId: string) => { lease: { runtimeFence: number } } + releaseOwner: (sessionId: string) => void +}): Promise { + const owner = input.owner(input.sessionId) + if (!owner) { + return false + } + const close = input.deps.transport?.closeTuiOwner ?? input.deps.transport?.waitForTuiExit + if (!close) { + throw new Error('The owning agent terminal could not be stopped.') + } + await close(owner) + const record = input.requireRecord(input.sessionId) + await input.deps.store.transitionHandoff(input.sessionId, (current) => + evictAgentSessionOwner({ + record: current, + expectedFence: record.lease.runtimeFence, + probe: { outcome: 'exit-observed' }, + now: input.deps.now() + }) + ) + input.releaseOwner(input.sessionId) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts new file mode 100644 index 00000000000..c16ac681226 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { + abandonStoredAgentSessionHandoffAttempt, + stopStoredAgentSessionOwnerForHandoff, + stopStoredRecoveringTuiOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export type StructuredAgentSessionRestartAccess = { + deps: StructuredAgentSessionHandoffDeps + requireRecord: (sessionId: string) => AgentSessionRecord + flowContext: () => StructuredAgentSessionHandoffFlowContext + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +} + +type ContinueHandoff = ( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord +) => Promise + +export async function recoverUnavailableTuiAsNative( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord, + continueHandoff: ContinueHandoff +): Promise { + await input.deps.transport!.stopRecoveredOwner(record) + if (record.lease.handoffStage === 'preparing') { + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + const abandoned = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + recoverableRuntimeKind: 'native', + now: input.deps.now() + }) + await continueHandoff(input, abandoned) + return + } + const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now()) + const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +export async function recoverTuiOwnerOrContinue( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord, + continueHandoff: ContinueHandoff +): Promise { + try { + const owner = await input.deps.transport!.recoverTuiOwner(record) + const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner }) + await persistReprovedTuiOwner(input, record.sessionId, reproved) + return reproved + } catch (error) { + const ownerState = await input.deps.transport!.probeRecoveredOwner?.(record) + if (ownerState !== 'dead') { + throw error + } + await recoverUnavailableTuiAsNative(input, record, continueHandoff) + return null + } +} + +export async function persistReprovedTuiOwner( + input: StructuredAgentSessionRestartAccess, + sessionId: string, + owner: StructuredTuiOwner +): Promise { + if (owner.link.origin === 'resumed') { + await input.deps.persistTuiProviderHandle?.({ + sessionId, + link: owner.link, + now: input.deps.now() + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts new file mode 100644 index 00000000000..13a26f2a7a9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -0,0 +1,306 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { + idleStructuredHandoffStatus, + structuredTuiRecoveryProofIsAdmissible +} from './structured-agent-session-handoff-status' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { + persistReprovedTuiOwner, + recoverTuiOwnerOrContinue, + recoverUnavailableTuiAsNative, + type StructuredAgentSessionRestartAccess +} from './structured-agent-session-handoff-restart-tui' + +type RestartAccess = StructuredAgentSessionRestartAccess + +export async function restoreStructuredAgentSessionHandoff( + input: RestartAccess, + sessionId: string +): Promise { + const initial = input.requireRecord(sessionId) + const operationId = initial.lease.handoffOperationId + const initialStage = initial.lease.handoffStage + if ( + (initialStage === 'recovering' || initialStage === 'manual-recovery') && + !canRestoreLiveTuiOwner(initial) + ) { + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_ownership_unknown' } + }) + } + input.setStatus(sessionId, idleStructuredHandoffStatus(initial)) + return + } + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await restoreOnce(input, input.requireRecord(sessionId)) + const settled = input.requireRecord(sessionId) + if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) { + throw new Error('Restart handoff reconciliation did not settle the transfer.') + } + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'succeeded', sessionId } + }) + } + return + } catch (error) { + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } + } + const current = input.requireRecord(sessionId) + const failed = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId, + fence: current.lease.runtimeFence, + stage: 'manual-recovery', + // Keep a live TUI retryable after restart; other records retain the failed operation. + handoffOperationId: + current.lease.runtimeKind === 'tui' && current.lease.claimStatus === 'live' + ? null + : operationId, + now: input.deps.now() + }) + const status = idleStructuredHandoffStatus(failed) + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } + input.setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: lastError instanceof Error ? lastError.message : String(lastError) + } + } + : {}) + }) +} + +async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { + if ( + record.lease.handoffStage === null && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + // Reconcile already proved the owner gone: no transfer is in flight and there is no process to + // recover, whatever kind the last owner was. Falling through would latch manual recovery on a + // host with no TUI transport — a state a user then has to clear by hand, for nothing. (Startup + // used to reach this path only for sessions it had not eagerly resumed, which is why removing + // that resume is what made it visible.) + return + } + if (canRestoreLiveTuiOwner(record)) { + await restoreRecoverableLiveTui(input, record) + return + } + if (!input.deps.transport) { + if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + return + } + if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') { + await restoreLiveTui(input, record) + return + } + if (!record.lease.handoffOperationId) { + return + } + if (record.lease.handoffStage === 'preparing') { + await restorePreparing(input, record) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + await restoreProving(input, record) + return + } + if (record.lease.handoffStage === 'old-owner-stopped') { + await continueHandoff(input, record) + } +} + +export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { + return structuredTuiRecoveryProofIsAdmissible(record) +} + +async function restoreRecoverableLiveTui( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + if (!input.deps.transport) { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + let owner: StructuredTuiOwner + try { + const recovered = await input.deps.transport.recoverTuiOwner(record) + owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + await persistReprovedTuiOwner(input, record.sessionId, owner) + } catch (error) { + const ownerState = await input.deps.transport.probeRecoveredOwner?.(record) + if (ownerState !== 'dead') { + throw error + } + await recoverUnavailableTuiAsNative(input, record, continueHandoff) + return + } + let settled = input.deps.store.getRecord(record.sessionId) ?? record + if (settled.lease.claimStatus === 'reserved') { + settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: null, + now: input.deps.now() + }) + settled = await input.deps.store.proveOwner({ + sessionId: settled.sessionId, + fence: settled.lease.runtimeFence, + link: owner.link, + now: input.deps.now() + }) + } else { + settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + } + input.retainOwner(record.sessionId, owner) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport.hostLabel + }) +} + +async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { + const owner = await input.deps.transport!.recoverTuiOwner(record) + await persistReprovedTuiOwner(input, record.sessionId, owner) + input.retainOwner(record.sessionId, owner) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport?.hostLabel + }) +} + +async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise { + if (record.lease.runtimeKind === 'tui') { + const owner = await recoverTuiOwnerOrContinue(input, record, continueHandoff) + if (!owner) { + return + } + const settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + await restoreLiveTui(input, settled) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise { + const operationId = record.lease.handoffOperationId! + if (record.lease.runtimeKind === 'tui') { + const reproved = await recoverTuiOwnerOrContinue(input, record, continueHandoff) + if (!reproved) { + return + } + await input.deps.store.proveOwner({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + link: reproved.link, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, reproved) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: reproved.terminal, + hostLabel: input.deps.transport?.hostLabel + }) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function startRecoveredTuiCatchup( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) + await input.deps.activateTuiHistoryCatchup?.(record.sessionId) +} + +async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { + const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' + const operationId = record.lease.handoffOperationId! + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: record.sessionId, + clientOperationId: operationId, + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: 'restart-reconciliation' + }, + direction, + mode: 'now', + action: 'retry' + } + await (direction === 'to-tui' + ? handoffStructuredSessionToTui(input.flowContext(), params, true) + : handoffStructuredSessionToNative(input.flowContext(), params, true)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts new file mode 100644 index 00000000000..0030760b957 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + rollbackStoredAgentSessionHandoffPreparation, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' +import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToNative( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean, + tuiAlreadyExited = false +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + let owner = context.owner(sessionId) + let transcriptPath = owner?.transcriptPath + if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) { + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-native') + } + record = context.requireRecord(sessionId) + try { + if (!owner) { + throw new Error('The owning agent terminal could not be identified.') + } + if (!tuiAlreadyExited) { + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + if (owner.link.origin === 'resumed') { + await deps.persistTuiProviderHandle?.({ sessionId, link: owner.link, now: deps.now() }) + } + } + transcriptPath = owner.transcriptPath ?? transcriptPath + if (owner.link.handle.provider === 'codex' && !transcriptPath) { + throw new Error( + 'The Codex terminal has not written a durable rollout yet. Send a prompt before switching to structured chat.' + ) + } + context.setStatus(sessionId, { + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'preparing', + operationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) + const exited = deps.transport!.closeTuiOwner + ? await deps.transport!.closeTuiOwner(owner) + : await deps.transport!.waitForTuiExit(owner) + transcriptPath = exited.transcriptPath ?? owner.transcriptPath + } catch (error) { + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' && + current.lease.ownerProcess + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } + throw error + } + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + } else if ( + record.lease.handoffStage !== 'old-owner-stopped' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + deps.stopTuiHistoryCatchup?.(sessionId) + if (owner?.historySource !== 'provider-resume') { + await deps.importTuiHistory({ + sessionId, + fence: record.lease.runtimeFence, + ...(transcriptPath ? { transcriptPath } : {}) + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + try { + record = await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + if (error instanceof AgentSessionAcquisitionExitUnprovenError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: deps.now() + }) + } + throw error + } + context.releaseOwner(sessionId) + deps.transport?.revealNativeSession?.({ + workspaceId: record.location.workspaceId, + sessionId, + agent: record.provider, + ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) + }) + context.setStatus(sessionId, { + owner: 'native', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts new file mode 100644 index 00000000000..2917fca2fe2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts @@ -0,0 +1,38 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffState { + private readonly statuses = new Map() + private readonly tuiOwners = new Map() + + constructor( + private readonly deps: { + requireRecord: (sessionId: string) => AgentSessionRecord + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + hostLabel?: string + } + ) {} + + status = (sessionId: string): AgentSessionHandoffStatus => { + const value = this.statuses.get(sessionId) + return value ?? idleStructuredHandoffStatus(this.deps.requireRecord(sessionId)) + } + + cachedStatus = (sessionId: string): AgentSessionHandoffStatus | undefined => + this.statuses.get(sessionId) + + owner = (sessionId: string): StructuredTuiOwner | undefined => this.tuiOwners.get(sessionId) + + retainOwner = (sessionId: string, owner: StructuredTuiOwner): void => { + this.tuiOwners.set(sessionId, owner) + } + + releaseOwner = (sessionId: string): void => void this.tuiOwners.delete(sessionId) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => { + this.statuses.set(sessionId, status) + this.deps.publish(sessionId, status) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts new file mode 100644 index 00000000000..7d519a558b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -0,0 +1,166 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function structuredTuiStatus( + owner: StructuredTuiOwner | undefined, + transport: StructuredAgentSessionHandoffTransport | undefined +): 'idle' | 'busy' { + return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy' +} + +export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.handoffOperationId + ) { + return persistedFailedStructuredHandoffStatus(record) + } + if (record.lease.handoffStage === 'manual-recovery') { + const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) + return { + owner: 'none', + direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native', + phase: 'failed', + stage: 'manual-recovery', + operationId: record.lease.handoffOperationId, + error: { + message: "Couldn't verify which runtime owns this session — manual recovery is required", + recoverableOwner: 'none', + ...(canRetryProof ? { canRetryProof: true } : {}) + } + } + } + if (record.lease.handoffStage) { + const direction = + record.lease.handoffStage === 'preparing' + ? record.lease.runtimeKind === 'native' + ? 'to-tui' + : 'to-native' + : record.lease.runtimeKind === 'tui' + ? 'to-tui' + : 'to-native' + return { + owner: + record.lease.claimStatus === 'live' && record.lease.ownerProcess + ? record.lease.runtimeKind + : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } + } + return { + owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } +} + +function persistedFailedStructuredHandoffStatus( + record: AgentSessionRecord +): AgentSessionHandoffStatus { + const recoverableOwner = record.lease.runtimeKind + const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native' + return { + owner: recoverableOwner, + direction, + phase: 'failed', + stage: 'old-owner-stopped', + operationId: record.lease.handoffOperationId, + error: { + message: + direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + recoverableOwner + } + } +} + +export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean { + return journal + .snapshot() + .items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export function switchingStructuredHandoffStatus( + record: AgentSessionRecord, + direction: 'to-tui' | 'to-native', + hostLabel?: string +): AgentSessionHandoffStatus { + return { + owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + ...(hostLabel ? { hostLabel } : {}) + } +} + +export function failedStructuredHandoffStatus( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest, + error: unknown, + hostLabel?: string +): AgentSessionHandoffStatus { + const recoverableOwner = + record.lease.handoffStage === 'manual-recovery' + ? 'none' + : record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released' + ? record.lease.runtimeKind + : record.lease.ownerProcess + ? record.lease.runtimeKind + : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' + ? 'native' + : 'none' + const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) + return { + owner: recoverableOwner, + direction: params.direction, + phase: 'failed', + stage: record.lease.handoffStage, + operationId: params.envelope.clientOperationId, + ...(hostLabel ? { hostLabel } : {}), + error: { + message: + recoverableOwner === 'none' + ? "Couldn't verify which runtime owns this session — manual recovery is required" + : params.direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + details: error instanceof Error ? error.message : String(error), + recoverableOwner, + ...(canRetryProof ? { canRetryProof: true } : {}) + } + } +} + +/** A latched TUI with a recorded process can be re-proved, regardless of which acquisition + * phase was interrupted. The operation ledger, not the lease, records the failed attempt. */ +export function structuredTuiRecoveryProofIsAdmissible(record: AgentSessionRecord): boolean { + return ( + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts new file mode 100644 index 00000000000..27769c8d5b4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -0,0 +1,112 @@ +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredTuiOwner = { + terminal: { handle: string; tabId: string; paneKey: string; ptyId: string } + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink + transcriptPath?: string + /** Codex app-server resume, not row-by-row legacy import, restores this owner's history. */ + historySource?: 'provider-resume' + /** This owner came from an existing terminal view rather than a structured-session tab. */ + adoptedTerminal?: true +} + +export class StructuredTuiLaunchCleanupError extends Error { + constructor( + launchError: unknown, + readonly cleanupError: unknown + ) { + super('The failed terminal launch could not be proven stopped.', { cause: launchError }) + this.name = 'StructuredTuiLaunchCleanupError' + } +} + +export type StructuredAgentSessionHandoffTransport = { + hostLabel: string + launchTui(input: { + record: AgentSessionRecord + fence: number + spawnToken: string + onSpawned?: (owner: StructuredTuiOwner) => Promise + }): Promise + reproveTuiOwner(input: { + record: AgentSessionRecord + owner: StructuredTuiOwner + }): Promise + recoverTuiOwner(record: AgentSessionRecord): Promise + probeRecoveredOwner?(record: AgentSessionRecord): Promise<'live' | 'dead' | 'unknown'> + stopRecoveredOwner(record: AgentSessionRecord): Promise + closeTuiOwner?(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + revealNativeSession?(input: { + workspaceId: string + sessionId: string + agent?: 'claude' | 'codex' + adoptedTerminal?: true + }): void + waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + waitForTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> + tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' + stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise +} + +export type StructuredNativeSuspendResult = + | { state: 'live' } + | { state: 'stopped' } + | { state: 'stopped-cleanup-failed'; error: unknown } + +export type StructuredAgentSessionHandoffDeps = { + store: AgentSessionRecordStore + claimKeyId: string + transport?: StructuredAgentSessionHandoffTransport + session: (sessionId: string) => { journal: AgentSessionJournal; fence: number } + suspendNative: (sessionId: string) => Promise + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise + importTuiHistory: (input: { + sessionId: string + fence: number + transcriptPath?: string + }) => Promise + prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + activateTuiHistoryCatchup?: (sessionId: string) => Promise + stopTuiHistoryCatchup?: (sessionId: string) => void + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + schedule: (sessionId: string, task: () => Promise) => Promise + now: () => number + /** Persist a provider handle observed while re-proving a TUI owner. */ + persistTuiProviderHandle?: (input: { + sessionId: string + link: AgentSessionProviderHandleLink + now: number + }) => Promise +} + +export type StructuredAgentSessionHandoffFlowContext = { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: ( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ) => Promise + publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void + requireRecord: (sessionId: string) => AgentSessionRecord +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts new file mode 100644 index 00000000000..4c9f1e69609 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -0,0 +1,384 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-handoff' +const PLAIN_RESIDUE = 'session-plain-residue' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let store: AgentSessionRecordStore +let journal: Awaited> +let coordinator: StructuredAgentSessionHandoffCoordinator +let statuses: AgentSessionHandoffStatus[] +type TransportMock = ReturnType< + typeof vi.fn< + Extract, (...args: never[]) => unknown> + > +> +let launchTui: TransportMock<'launchTui'> +let waitForTuiExit: TransportMock<'waitForTuiExit'> +let closeTuiOwner: TransportMock<'closeTuiOwner'> +let waitForTuiIdleOrExit: TransportMock<'waitForTuiIdleOrExit'> +let reproveTuiOwner: TransportMock<'reproveTuiOwner'> +let stopFailedTuiLaunch: TransportMock<'stopFailedTuiLaunch'> +let acquireNativeStop: ReturnType Promise>> +let acquireNativeCalls: number +let stopRecoveredOwner: TransportMock<'stopRecoveredOwner'> +let operations: number +type HistoryCatchup = (sessionId: string, fence: number) => Promise +let prepareTuiHistoryCatchup: ReturnType> +let recoverTuiHistoryCatchup: ReturnType> +let activateTuiHistoryCatchup: ReturnType Promise>> +let stopTuiHistoryCatchup: ReturnType void>> + +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function process(spawnToken: string, pid: number) { + return { + hostId: 'local', + pid, + processStartTimeMs: NOW - 1_000, + spawnToken + } +} + +function link(fence: number, id: string) { + return { + linkId: id, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } +} + +async function establishNativeOwner(): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'native-initial', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: process('native-initial', 4100), + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { ...link(fence, 'initial-link'), origin: 'created' }, + now: NOW + }) +} + +function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { + handle: 'term-tui', + tabId: 'tab-tui', + paneKey: 'tab-tui:leaf-tui', + ptyId: 'pty-tui' + }, + process: process(spawnToken, 4200), + link: link(fence, `tui-link-${fence}`), + transcriptPath: join(root, 'rollout.jsonl') + } +} + +function createCoordinator(): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui, + reproveTuiOwner, + recoverTuiOwner: async (record) => { + const owner = makeTuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ) + return { ...owner, process: record.lease.ownerProcess ?? owner.process } + }, + probeRecoveredOwner: async () => 'dead', + stopRecoveredOwner, + closeTuiOwner, + waitForTuiExit, + waitForTuiIdleOrExit, + tuiStatus: () => 'idle', + stopFailedTuiLaunch + }, + session: () => ({ + journal, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + }), + suspendNative: vi.fn(async () => ({ state: 'stopped' as const })), + acquireNative: async (input) => { + acquireNativeCalls += 1 + await store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: process(input.spawnToken, 4300 + acquireNativeCalls), + now: NOW + }) + return store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: link(input.fence, `native-link-${input.fence}`), + now: NOW + }) + }, + acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + prepareTuiHistoryCatchup, + recoverTuiHistoryCatchup, + activateTuiHistoryCatchup, + stopTuiHistoryCatchup, + publish: (_sessionId, status) => statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + operations = 0 + statuses = [] + acquireNativeCalls = 0 + prepareTuiHistoryCatchup = vi.fn(async () => undefined) + recoverTuiHistoryCatchup = vi.fn(async () => undefined) + activateTuiHistoryCatchup = vi.fn(async () => undefined) + stopTuiHistoryCatchup = vi.fn() + stopRecoveredOwner = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await establishNativeOwner() + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + closeTuiOwner = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + waitForTuiIdleOrExit = vi.fn(async () => 'idle') + reproveTuiOwner = vi.fn(async ({ owner }) => owner) + stopFailedTuiLaunch = vi.fn(async () => undefined) + acquireNativeStop = vi.fn(async () => true) + coordinator = createCoordinator() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +// The direction-agnostic restore path is the crash-during-acquisition recovery every +// plain direct launch depends on: restart adjudication parks a crashed acquire at a +// handoff stage, and restore() is what un-strands it. The interactive handoff request +// flow itself is deliberately absent from this build. +describe('structured session ownership recovery on restore', () => { + it('continues a persisted preparing stage after restart instead of stranding it', async () => { + const operation = operationId() + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('finishes a live new-owner-proving stage after restart', async () => { + const operation = operationId() + let record = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + record = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + operationId: operation, + now: NOW + }) + const spawnToken = 'restarted-tui' + record = await reserveStoredAgentSessionHandoffOwner(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId: operation, + claimKeyId: 'key-1', + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: record.lease.runtimeFence, + process: process(spawnToken, 4400), + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + expect(recoverTuiHistoryCatchup).toHaveBeenCalledWith( + SESSION, + store.getRecord(SESSION)?.lease.runtimeFence + ) + }) + + it('continues only the persisted TUI handoff after a store restart', async () => { + const plainOperation = operationId() + await store.reserveOwner({ + sessionId: PLAIN_RESIDUE, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'plain-residue-token', + claimKeyId: 'key-1', + handoffOperationId: plainOperation, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: plainOperation, fingerprint: 'plain-attach' }, + now: NOW + }) + const handoffOperation = operationId() + let interrupted = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: handoffOperation, + now: NOW + }) + interrupted = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: interrupted.lease.runtimeFence, + operationId: handoffOperation, + now: NOW + }) + const interruptedFence = interrupted.lease.runtimeFence + + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await store.reconcileOnRestart({ + probe: async (record) => + record.sessionId === PLAIN_RESIDUE + ? { outcome: 'indeterminate', reason: 'plain reservation cannot be attributed' } + : { outcome: 'pid-absent' }, + now: NOW + 1_000 + }) + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + coordinator = createCoordinator() + + await coordinator.restore(PLAIN_RESIDUE) + expect(launchTui).not.toHaveBeenCalled() + expect(acquireNativeCalls).toBe(0) + expect(store.getRecord(PLAIN_RESIDUE)?.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 1, + handoffStage: 'manual-recovery', + claimStatus: 'reserved', + reservedSpawnToken: 'plain-residue-token' + }) + expect( + store.listOperationRows().find((row) => row.operationId === plainOperation)?.outcome + ).toMatchObject({ status: 'failed', code: 'agent_session_ownership_unknown' }) + + await coordinator.restore(SESSION) + + expect(launchTui).toHaveBeenCalledOnce() + expect(acquireNativeCalls).toBe(1) + expect(launchTui.mock.calls[0]?.[0]).toMatchObject({ + record: { + sessionId: SESSION, + lease: { handoffOperationId: handoffOperation } + }, + fence: interruptedFence + 3 + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + runtimeFence: interruptedFence + 3, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'live' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts new file mode 100644 index 00000000000..0e213921609 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -0,0 +1,63 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { + createStructuredHandoffFlowContext, + requireStructuredHandoffRecord +} from './structured-agent-session-handoff-flow-context' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' +import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' + +export class StructuredAgentSessionHandoffCoordinator { + private readonly state: StructuredAgentSessionHandoffState + + constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { + // oxfmt-ignore + this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) + } + + status = (sessionId: string) => this.state.status(sessionId) + + closeRetainedTuiOwner = (sessionId: string): Promise => + closeRetainedTuiOwner({ + sessionId, + deps: this.deps, + owner: this.state.owner, + requireRecord: this.requireRecord, + releaseOwner: this.state.releaseOwner + }) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => + this.state.setStatus(sessionId, status) + + async restore(sessionId: string): Promise { + await restoreStructuredAgentSessionHandoff( + { + deps: this.deps, + requireRecord: (id) => this.requireRecord(id), + flowContext: () => this.flowContext(), + retainOwner: this.state.retainOwner, + setStatus: this.state.setStatus + }, + sessionId + ) + } + + private flowContext(): StructuredAgentSessionHandoffFlowContext { + return createStructuredHandoffFlowContext({ + deps: this.deps, + owner: this.state.owner, + retainOwner: this.state.retainOwner, + releaseOwner: this.state.releaseOwner, + setStatus: this.state.setStatus, + requireRecord: (sessionId) => this.requireRecord(sessionId) + }) + } + + private requireRecord = (sessionId: string): AgentSessionRecord => + requireStructuredHandoffRecord(this.deps, sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts new file mode 100644 index 00000000000..70fc9a43ed2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -0,0 +1,40 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + AgentSessionHistoryRequest, + AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' + +function providerSessionMetadata( + record: AgentSessionRecord | null +): AgentProviderSessionMetadata | undefined { + const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null + return head + ? { + key: 'session_id', + id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + } + : undefined +} + +export function readStructuredAgentSessionHistoryResult(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + request: AgentSessionHistoryRequest +}): AgentSessionHistoryResult { + const result = readAgentSessionHistory(input.journal, input.request) + const fence = input.record?.lease.runtimeFence + const providerSession = providerSessionMetadata(input.record) + if (fence === undefined) { + return providerSession ? { ...result, providerSession } : result + } + return { + ...result, + page: { ...result.page, fence }, + ...(result.ok ? {} : { fence }), + ...(providerSession ? { providerSession } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts new file mode 100644 index 00000000000..5ebdfed0114 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -0,0 +1,52 @@ +// Giving a held session its provider child back. +// +// This is the replacement for the startup resume, and the difference is only in WHO asks: the same +// eligibility rule, run when a surface binds instead of when the app launches. A write-capable hold +// must fail when acquisition is refused so the surface never mistakes a readable journal for a live +// provider child. + +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +export async function resumeHeldStructuredAgentSession(input: { + sessionId: string + deps: StructuredAgentSessionHostDeps + now: () => number + attach: ( + params: AgentSessionAttachParams + ) => Promise> +}): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + if (!adapterSupportsRecord(input.deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(input.now()) + ) + if (!params) { + throw new Error( + record.lease.unreconciled + ? 'execution_owner_reconciling' + : record.lease.claimStatus === 'conflicted' + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + ) + } + const attached = await input.attach(params) + if (!attached.ok) { + throw new Error(attached.refusal.code) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts new file mode 100644 index 00000000000..0771f288b65 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts @@ -0,0 +1,49 @@ +// Who WANTS this session alive, as a set of ids rather than a count. +// +// A refcount is the obvious shape and the wrong one. Every path that decrements it — a chat tab +// closing, a transport dying, a client retrying a release it already sent — can fire twice or not +// at all, and an integer cannot tell those apart: a duplicate release evicts a session somebody is +// still looking at, and a lost one leaks the child forever. A set answers both idempotently, +// because it records WHICH surface holds the session, not how many do. + +export class StructuredAgentSessionHolders { + private readonly bySession = new Map>() + + /** True when the session gained its FIRST holder — the edge that ends a pending release. */ + add(sessionId: string, holderId: string): boolean { + const holders = this.bySession.get(sessionId) + if (!holders) { + this.bySession.set(sessionId, new Set([holderId])) + return true + } + holders.add(holderId) + return false + } + + /** True when the session lost its LAST holder — the edge that starts one. */ + remove(sessionId: string, holderId: string): boolean { + const holders = this.bySession.get(sessionId) + if (!holders?.delete(holderId) || holders.size > 0) { + return false + } + this.bySession.delete(sessionId) + return true + } + + isHeld(sessionId: string): boolean { + return (this.bySession.get(sessionId)?.size ?? 0) > 0 + } + + has(sessionId: string, holderId: string): boolean { + return this.bySession.get(sessionId)?.has(holderId) ?? false + } + + holderIds(sessionId: string): string[] { + return [...(this.bySession.get(sessionId) ?? [])] + } + + /** Drops every holder of one session without evaluating the edge, for a session that is gone. */ + forget(sessionId: string): void { + this.bySession.delete(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts new file mode 100644 index 00000000000..88f4079fc7f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts @@ -0,0 +1,231 @@ +// The parts a session's lifetime is assembled from: the holder set, the release clock, and the +// deadline that keeps teardown from hanging. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionHolders } from './structured-agent-session-holders' +import { StructuredAgentSessionReleaseClock } from './structured-agent-session-release-clock' +import { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import { + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + evictStructuredAgentSession +} from './structured-agent-session-eviction' +import { + StructuredAgentSessionEvictionTimeoutError, + withStructuredAgentSessionEvictionDeadline +} from './structured-agent-session-eviction-deadline' + +const clocks: StructuredAgentSessionReleaseClock[] = [] + +function clock(deps: { + isTurnActive?: () => boolean + isHeld?: () => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void +}): StructuredAgentSessionReleaseClock { + const created = new StructuredAgentSessionReleaseClock({ + isTurnActive: deps.isTurnActive ?? (() => false), + isHeld: deps.isHeld ?? (() => false), + evict: deps.evict, + ...(deps.onError ? { onError: deps.onError } : {}), + graceMs: 1 + }) + clocks.push(created) + return created +} + +afterEach(() => { + for (const created of clocks.splice(0)) { + created.dispose() + } +}) + +describe('the holder set', () => { + it('reports the first and last holder, and nothing in between', () => { + const holders = new StructuredAgentSessionHolders() + + expect(holders.add('session-1', 'a')).toBe(true) + expect(holders.add('session-1', 'b')).toBe(false) + expect(holders.remove('session-1', 'a')).toBe(false) + expect(holders.remove('session-1', 'b')).toBe(true) + expect(holders.isHeld('session-1')).toBe(false) + }) + + // The reason this is a set and not a count: every release path can fire twice or not at all. + it('absorbs a duplicate hold and a duplicate release', () => { + const holders = new StructuredAgentSessionHolders() + + holders.add('session-1', 'a') + holders.add('session-1', 'a') + expect(holders.remove('session-1', 'a')).toBe(true) + expect(holders.remove('session-1', 'a')).toBe(false) + expect(holders.holderIds('session-1')).toEqual([]) + }) + + it('keeps one session holders out of another session holders', () => { + const holders = new StructuredAgentSessionHolders() + + holders.add('session-1', 'a') + holders.add('session-2', 'a') + holders.remove('session-1', 'a') + + expect(holders.isHeld('session-1')).toBe(false) + expect(holders.isHeld('session-2')).toBe(true) + }) +}) + +describe('the release clock', () => { + it('waits out a running turn instead of evicting into it', async () => { + const evict = vi.fn(async () => {}) + let turnRunning = true + const releasing = clock({ isTurnActive: () => turnRunning, evict }) + + releasing.arm('session-1') + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(evict).not.toHaveBeenCalled() + + turnRunning = false + await vi.waitFor(() => expect(evict).toHaveBeenCalledWith('session-1')) + }) + + it('stands down when a holder arrives during the wait', async () => { + const evict = vi.fn(async () => {}) + const releasing = clock({ isHeld: () => true, evict }) + + releasing.arm('session-1') + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(evict).not.toHaveBeenCalled() + }) + + it('reports a failed eviction rather than swallowing it', async () => { + const onError = vi.fn() + const releasing = clock({ + evict: async () => { + throw new Error('child would not stop') + }, + onError + }) + + releasing.arm('session-1') + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-1', + error: expect.objectContaining({ message: 'child would not stop' }) + }) + ) + }) +}) + +describe('holds', () => { + it('resumes a session on its first hold and not on a retained one', async () => { + let child = false + const resume = vi.fn(async () => { + child = true + }) + const holds = new StructuredAgentSessionHolds({ + resume, + hasProviderChild: () => child, + isTurnActive: () => false, + evict: async () => {}, + graceMs: 1 + }) + + await holds.hold('session-1', 'stream-1', { resume: false }) + expect(resume).not.toHaveBeenCalled() + + await holds.hold('session-1', 'chat-1') + expect(resume).toHaveBeenCalledOnce() + + child = true + await holds.hold('session-1', 'chat-2') + expect(resume).toHaveBeenCalledOnce() + holds.dispose() + }) + + it('never arms the clock for a session with nothing to stop', async () => { + const evict = vi.fn(async () => {}) + const holds = new StructuredAgentSessionHolds({ + resume: async () => {}, + hasProviderChild: () => false, + isTurnActive: () => false, + evict, + graceMs: 1 + }) + + await holds.hold('session-1', 'chat-1', { resume: false }) + holds.release('session-1', 'chat-1') + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(evict).not.toHaveBeenCalled() + expect(holds.isReleasePending('session-1')).toBe(false) + holds.dispose() + }) + + it('fails a write-capable hold when resume proves no provider child', async () => { + const holds = new StructuredAgentSessionHolds({ + resume: async () => {}, + hasProviderChild: () => false, + isTurnActive: () => false, + evict: async () => {}, + graceMs: 1 + }) + + await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow( + 'agent_session_ownership_unknown' + ) + expect(holds.isHeld('session-1')).toBe(false) + holds.dispose() + }) +}) + +describe('the teardown deadline', () => { + it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { + const forget = vi.fn() + const releaseLease = vi.fn(async () => {}) + + await expect( + evictStructuredAgentSession( + { + sessionId: 'session-1', + eventSink: { + unbind: vi.fn(), + drained: vi.fn(async () => {}), + close: vi.fn() + } as never, + adapter: { closeSession: () => new Promise(() => {}) } as never, + forget, + discardSink: vi.fn(), + releaseLease + }, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS, 5) + ) + ).rejects.toMatchObject({ step: 'stop-provider-child' }) + + expect(forget).not.toHaveBeenCalled() + expect(releaseLease).not.toHaveBeenCalled() + }) + + it('names the step that ran out of time', async () => { + const [step] = withStructuredAgentSessionEvictionDeadline( + [{ name: 'slow-step', run: () => new Promise(() => {}) }], + 5 + ) + + await expect(step?.run({} as never)).rejects.toBeInstanceOf( + StructuredAgentSessionEvictionTimeoutError + ) + }) + + it('does not delay a step that finishes', async () => { + const ran: string[] = [] + const steps = withStructuredAgentSessionEvictionDeadline( + [{ name: 'fast-step', run: () => void ran.push('fast-step') }], + 5_000 + ) + + await steps[0]?.run({} as never) + + expect(ran).toEqual(['fast-step']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts new file mode 100644 index 00000000000..eac3554783f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts @@ -0,0 +1,103 @@ +// The lifetime of a structured session, tied to the surfaces that want one. +// +// Nothing used to tell the host that a chat WANTED a session, and nothing told it when a chat +// stopped wanting one. Both halves of that gap cost real processes: sessions nobody had opened got +// an app-server at every launch, and sessions the user closed kept theirs until the app quit. +// +// A surface takes a hold when it binds and drops it when it goes away. The first hold on a session +// with no child resumes it — that, and not the shape of a lease on disk, is what makes a provider +// process exist. The last hold leaving starts the release clock. Transport close is the BACKSTOP, +// not the mechanism: a client that vanishes mid-flight never sends its release, so the caller +// registers one against the connection and the holder set absorbs the duplicate. + +import { + StructuredAgentSessionReleaseClock, + type StructuredAgentSessionReleaseClockDeps +} from './structured-agent-session-release-clock' +import { StructuredAgentSessionHolders } from './structured-agent-session-holders' + +export type StructuredAgentSessionHoldsDeps = { + /** Acquires a provider child for a session that has none. A no-op when one is already live. */ + resume: (sessionId: string) => Promise + /** Whether evicting this session would actually free anything. */ + hasProviderChild: (sessionId: string) => boolean + isTurnActive: (sessionId: string) => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + graceMs?: number +} + +export type StructuredAgentSessionHoldOptions = { + /** False for a hold that only RETAINS — a subscription stream, which must not make a child + * exist just by reading history. */ + resume?: boolean +} + +export class StructuredAgentSessionHolds { + private readonly holders = new StructuredAgentSessionHolders() + private readonly clock: StructuredAgentSessionReleaseClock + + constructor(private readonly deps: StructuredAgentSessionHoldsDeps) { + const clockDeps: StructuredAgentSessionReleaseClockDeps = { + isTurnActive: deps.isTurnActive, + isHeld: (sessionId) => this.holders.isHeld(sessionId), + evict: (sessionId) => this.deps.evict(sessionId), + ...(deps.onError ? { onError: deps.onError } : {}), + ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }) + } + this.clock = new StructuredAgentSessionReleaseClock(clockDeps) + } + + async hold( + sessionId: string, + holderId: string, + options: StructuredAgentSessionHoldOptions = {} + ): Promise { + const alreadyHeld = this.holders.has(sessionId, holderId) + this.holders.add(sessionId, holderId) + // Unconditional, not only on the first-holder edge: a second surface arriving during the grace + // window must cancel the pending release too. + this.clock.cancel(sessionId) + if (options.resume === false || this.deps.hasProviderChild(sessionId)) { + return + } + try { + await this.deps.resume(sessionId) + if (!this.deps.hasProviderChild(sessionId)) { + throw new Error('agent_session_ownership_unknown') + } + } catch (error) { + if (!alreadyHeld) { + this.holders.remove(sessionId, holderId) + } + throw error + } + } + + release(sessionId: string, holderId: string): void { + if (!this.holders.remove(sessionId, holderId)) { + return + } + if (this.deps.hasProviderChild(sessionId)) { + this.clock.arm(sessionId) + } + } + + /** Drops the holders of a session that is gone, whoever evicted it. */ + forget(sessionId: string): void { + this.clock.cancel(sessionId) + this.holders.forget(sessionId) + } + + isHeld(sessionId: string): boolean { + return this.holders.isHeld(sessionId) + } + + isReleasePending(sessionId: string): boolean { + return this.clock.isArmed(sessionId) + } + + dispose(): void { + this.clock.dispose() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts new file mode 100644 index 00000000000..245cdec9f17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff' + +function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord { + return { + provider, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: accountHome + } + } as AgentSessionRecord +} + +describe('structured TUI transcript import roots', () => { + it('uses the managed Claude account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual( + { + claudeProjectsDir: join('/managed/claude', 'projects') + } + ) + }) + + it('uses the managed Codex account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({ + codexSessionsDirs: [join('/managed/codex', 'sessions')] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts new file mode 100644 index 00000000000..e52197f14db --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -0,0 +1,225 @@ +import { join } from 'node:path' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { journalIdentityFor } from './structured-agent-session-attach' +import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' +import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui-recovery' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' + +type HostHandoffAccess = { + session: (sessionId: string) => StructuredAgentSessionHostSession + eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink + flush: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + subscribers: AgentSessionSubscribers + now: () => number +} + +export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator & { + stopTuiHistoryCatchup: () => void + recoverDeadTuiOwner: ( + sessionId: string, + expectedFence: number, + probe: AgentSessionOwnerProbe + ) => Promise +} + +export async function refreshRecoverableStructuredHandoffStatus( + handoff: StructuredAgentSessionHostHandoff, + store: StructuredAgentSessionHostDeps['store'], + sessionId: string +) { + const record = store.getRecord(sessionId) + if (record && canRestoreLiveTuiOwner(record)) { + await handoff.restore(sessionId) + } + return handoff.status(sessionId) +} + +export function createStructuredAgentSessionHostHandoff( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess +): StructuredAgentSessionHostHandoff { + const tuiHistoryCatchup = new StructuredTuiTranscriptCatchup({ + store: deps.store, + session: host.session, + schedule: host.serialize, + publish: (sessionId) => { + const session = host.session(sessionId) + host.subscribers.publish(sessionId, session.journal) + }, + reset: (sessionId, fence) => { + const session = host.session(sessionId) + host.subscribers.reset(sessionId, session.journal, 'epoch_changed', fence) + }, + ...(deps.onEventSinkError ? { onError: deps.onEventSinkError } : {}) + }) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store: deps.store, + claimKeyId: deps.claimKeyId, + ...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}), + session: host.session, + suspendNative: async (sessionId) => { + if (!deps.adapter.closeSession) { + return { state: 'live' } + } + const exited = await deps.adapter.closeSession(sessionId) + if (exited !== true) { + // Report the unproven exit; the forward handoff refuses on it. + return { state: 'live' } + } + host.session(sessionId).hasProviderChild = false + try { + await host.flush(sessionId) + host.eventSink(sessionId).unbind() + return { state: 'stopped' } + } catch (error) { + return { state: 'stopped-cleanup-failed', error } + } + }, + acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input), + acquireNativeStop: async (sessionId, turnId, fence) => + (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, + importTuiHistory: (input) => importTuiHistory(deps, host, input), + prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence), + recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence), + activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId), + stopTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.stop(sessionId), + publish: (sessionId, status) => { + const session = host.session(sessionId) + const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence + host.subscribers.handoff(sessionId, fence, status) + }, + schedule: host.serialize, + now: host.now, + ...(deps.persistTuiProviderHandle + ? { persistTuiProviderHandle: deps.persistTuiProviderHandle } + : {}) + }) + return Object.assign(coordinator, { + stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll(), + recoverDeadTuiOwner: async ( + sessionId: string, + expectedFence: number, + probe: AgentSessionOwnerProbe + ) => { + const record = deps.store.getRecord(sessionId) + if (!record) { + return + } + const status = await recoverDeadTuiHandoffStatus({ + store: deps.store, + now: host.now, + record, + expectedFence, + probe + }) + if (status) { + coordinator.setStatus(sessionId, status) + } + } + }) +} + +async function importTuiHistory( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; transcriptPath?: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + const head = record?.providerHandleChain.at(-1) + if (!record || !head) { + throw new Error('agent_session_identity_required') + } + const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath) + const providerSessionId = + head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const imported = await importLegacyTranscriptIntoJournal({ + journal: session.journal, + agent: head.handle.provider, + sessionId: providerSessionId, + fence: input.fence, + options + }) + if (!imported.ok) { + throw new Error(imported.error) + } + host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) +} + +export function structuredTuiTranscriptImportOptions( + record: AgentSessionRecord, + transcriptPath?: string +): LegacyImportOptions { + if (transcriptPath) { + return { filePath: transcriptPath } + } + return record.provider === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +} + +async function acquireNativeHandoffOwner( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; spawnToken: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const eventSink = host.eventSink(input.sessionId) + eventSink.unbind() + await eventSink.drained() + const acquired = await deps.adapter.acquire({ + identity: journalIdentityFor(record, session.params), + fence: input.fence, + spawnToken: input.spawnToken, + ...(record.options ? { options: record.options } : {}), + events: eventSink.sink + }) + let proved: AgentSessionRecord + try { + const options = await readNativeSessionOptions({ + adapter: deps.adapter, + sessionId: input.sessionId, + fence: input.fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + await deps.store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: acquired.process, + now: host.now() + }) + proved = await deps.store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: acquired.link, + now: host.now(), + ...(options ? { options } : {}) + }) + } catch (error) { + return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error) + } + session.hasProviderChild = true + session.fence = proved.lease.runtimeFence + eventSink.bind({ + journal: session.journal, + fence: proved.lease.runtimeFence, + publish: () => host.subscribers.publish(input.sessionId, session.journal) + }) + host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence) + return proved +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts new file mode 100644 index 00000000000..5f3bbc5c731 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -0,0 +1,87 @@ +// The host's half of a session's lifetime: what a close does, and what a hold is wired to. +// +// Lifted out of the host for the same reason attaching was — the host is a coordinator, and the +// sequence that stops a provider child and hands its lease back reads better next to the holder +// bookkeeping that decides when to run it than buried among the twenty other things a session can +// do. + +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + evictStructuredAgentSession, + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + type StructuredAgentSessionEvictionContext +} from './structured-agent-session-eviction' +import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' +import { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' + +export type StructuredAgentSessionLifetimeContext = { + deps: StructuredAgentSessionHostDeps + runtimeState: StructuredAgentSessionHostRuntimeState + sessions: Map + now: () => number +} + +function hasProviderChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): boolean { + return context.sessions.get(sessionId)?.hasProviderChild === true +} + +/** Runs the eviction steps under a deadline. A step that fails — or runs out of time — aborts the + * rest, which leaves the session indexed and the child loaded so the next close is a real retry. */ +export async function evictHeldStructuredAgentSession( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + if (!context.sessions.has(sessionId)) { + return + } + const eviction: StructuredAgentSessionEvictionContext = { + sessionId, + hasProviderChild: hasProviderChild(context, sessionId), + eventSink: context.runtimeState.eventSinkFor(sessionId), + adapter: context.deps.adapter, + forget: () => context.sessions.delete(sessionId), + discardSink: () => context.runtimeState.discardEventSink(sessionId), + releaseLease: () => + releaseStoredStructuredAgentSessionOwner({ + store: context.deps.store, + sessionId, + hasProviderChild: hasProviderChild(context, sessionId), + now: context.now() + }) + } + await evictStructuredAgentSession( + eviction, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + ) +} + +export function createStructuredAgentSessionHolds( + context: StructuredAgentSessionLifetimeContext, + input: { + resume: (sessionId: string) => Promise + evict: (sessionId: string) => Promise + } +): StructuredAgentSessionHolds { + return new StructuredAgentSessionHolds({ + resume: input.resume, + evict: input.evict, + hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), + isTurnActive: (sessionId) => { + const session = context.sessions.get(sessionId) + return session + ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null + : false + }, + onError: (error) => context.deps.onEventSinkError?.(error), + ...(context.deps.releaseGraceMs === undefined ? {} : { graceMs: context.deps.releaseGraceMs }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts new file mode 100644 index 00000000000..c9afa06e525 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -0,0 +1,115 @@ +// Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a +// prompt, change an option, read the options back. +// +// They share one shape — admit the envelope against the lease, run a plan, publish the journal — so +// they share one path here rather than five copies in the host. The host keeps attach, holds and +// teardown; this is the surface that assumes those already happened. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionOptionsResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { + cancelPlan, + promptPlan, + sendPlan, + setOptionPlan, + type MutationPlan +} from './structured-agent-session-mutation-plans' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' + +export type StructuredAgentSessionMutationContext = { + deps: StructuredAgentSessionHostDeps + sessions: Map + publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + requireSession: (sessionId: string) => StructuredAgentSessionHostSession + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number +} + +function mutate( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): Promise> { + return context.serialize(envelope.sessionId, () => + admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + callerKey: caller.callerKey, + envelope, + plan, + journal: context.sessions.get(envelope.sessionId)?.journal, + publish: (journal) => context.publish(envelope.sessionId, journal), + now: () => context.now() + }) + ) +} + +export function sendStructuredAgentSessionTurn( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + retryUnknown?: true + beforeRun?: () => void + } +): Promise> { + return mutate(context, caller, params.envelope, sendPlan(params)) +} + +export function cancelStructuredAgentSessionTurn( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; turnId: string } +): Promise> { + return mutate(context, caller, params.envelope, cancelPlan(params)) +} + +export function respondToStructuredAgentSessionPrompt( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string + } +): Promise> { + return mutate(context, caller, params.envelope, promptPlan(params)) +} + +export function setStructuredAgentSessionOption( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } +): Promise> { + return mutate(context, caller, params.envelope, setOptionPlan(params)) +} + +export function readStructuredAgentSessionOptions( + context: StructuredAgentSessionMutationContext, + sessionId: string +): Promise { + return context.serialize(sessionId, async () => { + const session = context.requireSession(sessionId) + if (!context.deps.adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return context.deps.adapter.readOptions({ sessionId, fence: session.fence }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts new file mode 100644 index 00000000000..80e4da5370b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' + +const NOW = 1_800_000_000_000 + +function reservedRecord(): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: 'session-probe', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-probe', + runtimeKind: 'native', + runtimeFence: 3, + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: 'spawn-probe', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'reserved', + unreconciled: false, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +function runtimeState( + record: AgentSessionRecord | null, + probeOwner: NonNullable +) { + const deps = { + store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + adapter: {}, + journalRoot: '/tmp', + claimKeyId: 'key-1', + probeOwner + } as StructuredAgentSessionHostDeps + return new StructuredAgentSessionHostRuntimeState(deps) +} + +describe('host runtime-state owner probe', () => { + it('routes an ownerless reservation through the strict probe instead of fabricating proof', async () => { + // Fabricating `reservation-unused` here skipped the processless-proof rule the runtime + // probe enforces — the exact answer that mints a second writer on one provider session. + const probeOwner = vi.fn(async () => ({ + outcome: 'indeterminate' as const, + reason: 'reservation named no process' + })) + const state = runtimeState(reservedRecord(), probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'indeterminate', + reason: 'reservation named no process' + }) + expect(probeOwner).toHaveBeenCalledTimes(1) + }) + + it('skips the probe for a released ownerless record acquisition never consults it for', async () => { + const released = reservedRecord() + released.lease.claimStatus = 'released' + released.lease.handoffStage = null + released.lease.reservedSpawnToken = null + const probeOwner = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'x' })) + const state = runtimeState(released, probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'reservation-unused' + }) + expect(probeOwner).not.toHaveBeenCalled() + }) + + it('treats a session with no record at all as an unused reservation', async () => { + const probeOwner = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'x' })) + const state = runtimeState(null, probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'reservation-unused' + }) + expect(probeOwner).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts new file mode 100644 index 00000000000..d1db05df872 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -0,0 +1,99 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + createDeferredStructuredAgentSessionEventSink, + type DeferredStructuredAgentSessionEventSink +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' + +export class StructuredAgentSessionHostRuntimeState { + private readonly eventSinks = new Map() + private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer + + constructor( + private readonly deps: StructuredAgentSessionHostDeps, + onLeaseRenewed?: (record: AgentSessionRecord) => Promise, + onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise + ) { + this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ + store: deps.store, + probe: (record) => this.probeRecord(record), + ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + now: () => deps.now?.() ?? Date.now(), + ...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}), + ...(onDeadTuiOwner ? { onDeadTuiOwner } : {}), + onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + }) + } + + startLeaseRenewal(): void { + this.leaseRenewer.start() + } + + stopLeaseRenewal(): void { + this.leaseRenewer.stop() + } + + eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { + const existing = this.eventSinks.get(sessionId) + if (existing) { + return existing + } + const created = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) + this.eventSinks.set(sessionId, created) + return created + } + + discardEventSink(sessionId: string): void { + this.eventSinks.delete(sessionId) + } + + flushEventSink(sessionId: string): Promise { + return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + } + + async flushAllEventSinks(): Promise { + await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) + } + + /** Exit from a latched recovery stage when present-time evidence permits one. */ + resolveRecovery(sessionId: string): Promise<'resolved' | 'unresolved' | 'not-applicable'> { + return resolveStructuredSessionRecovery( + { + store: this.deps.store, + probeRecord: (record) => this.probeRecord(record), + now: () => this.deps.now?.() ?? Date.now(), + ...(this.deps.stopOwnerProcess ? { stopOwnerProcess: this.deps.stopOwnerProcess } : {}) + }, + sessionId + ) + } + + probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if ( + !record || + (record.lease.ownerProcess === null && record.lease.claimStatus !== 'reserved') + ) { + // Acquisition only consults the probe against a recorded owner or a live reservation. + return Promise.resolve({ outcome: 'reservation-unused' }) + } + // A live reservation goes through the strict probe: calling it unused without its + // processless proof is the answer that mints a second writer. + return this.probeRecord(record) + } + + probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts new file mode 100644 index 00000000000..122907c4737 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -0,0 +1,20 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: AgentSessionRecord['provider'] +} + +export function listStructuredAgentSessionTabs( + sessions: ReadonlyMap< + string, + { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } + > +): StructuredAgentSessionTab[] { + return [...sessions.entries()].map(([sessionId, session]) => ({ + sessionId, + workspaceId: session.params.location.workspaceId, + agent: session.params.provider + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts new file mode 100644 index 00000000000..69e6d029d60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -0,0 +1,63 @@ +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +export const HOST_TEST_NOW = 1_800_000_000_000 +export const HOST_TEST_SESSION = 'session-alpha' +export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +let operations = 0 + +export function resetHostTestOperationIds(): void { + operations = 0 +} + +export function hostTestOperationId(): string { + operations += 1 + return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` +} + +export function hostTestMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export function hostTestAttachParams( + expectedRuntimeFence: number | null, + overrides: Partial = {} +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: '0'.repeat(64) + }, + location: HOST_TEST_LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts new file mode 100644 index 00000000000..f4d7ed7608b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -0,0 +1,53 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +export type StructuredAgentSessionCaller = { callerKey: string } + +export type StructuredAgentSessionHostSession = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number + /** Whether THIS host generation is running the provider process behind the session. A journal + * restored for reading has none, and neither has a session a TUI owns — so neither may be + * evicted to free a child, and neither may have its lease released as an observed exit. */ + hasProviderChild: boolean +} + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + probeOwners?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + /** Recovery-exit stop requests only; a lease moves only on a later proven-absent probe. */ + stopOwnerProcess?: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void + /** Host spawn-token process scan; null means the platform cannot enumerate, never "none". */ + scanSpawnTokenProcesses?: () => Promise + mintSpawnToken?: () => string + resolveLaunchArgs?: ( + provider: AgentSessionRecord['provider'] + ) => Promise | string[] | undefined + resolveLaunchEnv?: ( + provider: AgentSessionRecord['provider'] + ) => Promise | undefined> | Record | undefined + now?: () => number + persistTuiProviderHandle?: (input: { + sessionId: string + link: AgentSessionProviderHandleLink + now: number + }) => Promise + /** How long a session outlives its last surface. Tests drive this; production takes the default. */ + releaseGraceMs?: number + onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts new file mode 100644 index 00000000000..c32958a1abc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -0,0 +1,879 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +function envelope( + method: string, + fields: Record, + overrides: Partial = {} +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }), + ...overrides + } +} + +const attachParams = ( + overrides: Partial = {} +): AgentSessionAttachParams => hostTestAttachParams(null, overrides) + +const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let releaseAcquisition: Mock> +let dispatch: Mock +let cancelTurn: Mock +let answerPrompt: Mock +let setOption: Mock +let ordinal = 0 + +function accepted(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition, + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +async function attach(): Promise { + const result = await host.attach(CALLER, attachParams()) + expect(result.ok).toBe(true) + return store.getRecord(SESSION) +} + +/** Puts a pending approval in the journal BEFORE attach, which is the only way + * 1d can stage one: the adapter that would emit it is phase 2's. */ +async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir + }) + const appended = await journal.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) + resetHostTestOperationIds() + ordinal = 0 + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + releaseAcquisition = vi.fn(async () => true) + dispatch = vi.fn(async () => accepted()) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + answerPrompt = vi.fn(async () => undefined) + setOption = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('attach', () => { + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { + const result = await host.attach(CALLER, attachParams()) + expect(result).toMatchObject({ ok: true, replayed: false }) + const record = store.getRecord(SESSION) + expect(record?.lease.ownerProcess?.pid).toBe(4242) + expect(record?.lease.handoffStage).toBeNull() + }) + + it('refuses a payload the client fingerprinted wrong', async () => { + const params = attachParams() + const result = await host.attach(CALLER, { + ...params, + envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + }) + + it('refuses a provider handle that belongs to a different provider', async () => { + const params = attachParams({ + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('refuses a second create against a live session', async () => { + await attach() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + }) + + it('replays a retried attach instead of reserving a second owner', async () => { + const params = attachParams() + await host.attach(CALLER, params) + const retry = await host.attach(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + }) + + it('retires a failed proved acquisition before admitting a fresh operation', async () => { + const acquire = vi + .fn() + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: 'stale-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence + 1, + observedAt: NOW + } + })) + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent_session_provider_handle_stale_fence' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) + }) + + it('reaps an acquisition when process identity commit fails', async () => { + vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed')) + + await expect(host.attach(CALLER, attachParams())).rejects.toThrow('commit failed') + + expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) + }) + + it('drains writes captured by the old journal before acquiring its replacement', async () => { + const record = await attach() + const events = acquire.mock.calls[0]?.[0].events + const oldJournal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const appendGate = Promise.withResolvers() + const originalAppend = oldJournal.appendItem.bind(oldJournal) + const append = vi.spyOn(oldJournal, 'appendItem').mockImplementationOnce(async (...args) => { + await appendGate.promise + return originalAppend(...args) + }) + events?.appendItem( + { provider: 'orca', clientMessageId: 'old-journal-write' }, + { kind: 'status', text: 'old journal write' } + ) + await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + await new Promise((resolve) => setImmediate(resolve)) + expect(acquire).toHaveBeenCalledTimes(1) + + appendGate.resolve() + await expect(replacement).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + }) +}) + +describe('send', () => { + it('writes the submission before dispatching and resolves it accepted', async () => { + await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!result.ok) { + throw new Error(`expected a send, got ${result.refusal.code}`) + } + expect(result.value.submission.dispatchState).toBe('accepted') + expect(dispatch).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(1) + expect(page.ok && page.page.fence).toBe(1) + expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) + }) + + it('settles a thrown dispatch as unknown, never as a rejection', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + }) + + it('replays a retried send from the journal without dispatching twice', async () => { + await attach() + const body = hostTestMessage('add a retry') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const retry = await host.send(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('redispatches an explicitly retried durable unknown without appending a second submission', async () => { + await attach() + dispatch + .mockRejectedValueOnce(new Error('socket closed')) + .mockImplementationOnce(async () => accepted()) + const body = hostTestMessage('possibly delivered') + const params = { envelope: envelope('agentSession.send', { body }), body } + + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + const retried = await host.send(CALLER, { ...params, retryUnknown: true }) + + expect(retried).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(2) + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(state.ok && state.page.submissions).toHaveLength(1) + }) + + it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { + await attach() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) + const body = hostTestMessage('possibly delivered before persistence failed') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.outcome + ).toEqual({ status: 'unknown' }) + expect(dispatch).toHaveBeenCalledTimes(1) + + await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(journal.submissions()).toHaveLength(1) + }) + + it('refuses a stale fence and hands back the current one', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } + }) + }) + + it('does not let a refused call leave a ledger row that replays past the fence', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + } + await host.send(CALLER, params) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('refuses any mutation against a session this host has not attached', async () => { + const body = hostTestMessage('add a retry') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + }) +}) + +describe('cancel', () => { + it('records the outcome as a status item keyed by the operation id', async () => { + await attach() + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items[0]?.body).toMatchObject({ + kind: 'status', + text: 'Turn cancelled.' + }) + expect(JSON.stringify(page.ok && page.page.items[0]?.body)).not.toContain('turn-1') + }) + + it('reports an unconfirmed cancellation rather than failing the call', async () => { + await attach() + cancelTurn.mockRejectedValueOnce(new Error('no answer')) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + }) + + it('never interrupts twice on a replay', async () => { + await attach() + const params = { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + } + await host.cancel(CALLER, params) + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) +}) + +describe('respondToPrompt', () => { + it('commits the answer before the provider callback', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: 'allow' } } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses a second answer to one prompt and says which answer won', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + const loser = await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + } + ) + expect(loser).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + resolution: { selectedOptionId: 'allow' } + } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses an option the prompt does not offer', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) + expect(answerPrompt).not.toHaveBeenCalled() + }) + + it("does not turn a recorded refusal into another client's successful answer", async () => { + const prompt = await seedApproval() + await attach() + const rejectedFields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'deny' + } + const rejected = { + envelope: envelope('agentSession.respondTo:approval', rejectedFields), + kind: 'approval' as const, + ...rejectedFields + } + await host.respondToPrompt(CALLER, rejected) + + const acceptedFields = { ...rejectedFields, optionId: 'allow' } + await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', acceptedFields), + kind: 'approval', + ...acceptedFields + } + ) + + expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + }) + + it('keeps the answer and reports it undelivered when the provider callback throws', async () => { + const prompt = await seedApproval() + await attach() + answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result.ok).toBe(true) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const statusId = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${prompt.itemId}#delivery` + }) + expect(page.ok && page.page.items.some((entry) => entry.itemId === statusId)).toBe(true) + }) +}) + +describe('setOption', () => { + it('goes to the provider and writes nothing to the journal', async () => { + await attach() + setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + const result = await host.setOption(CALLER, params) + expect(result).toMatchObject({ + ok: true, + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + }) + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + }) + expect(setOption).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(0) + }) + + it('does not turn an unknown provider outcome into a successful replay', async () => { + await attach() + setOption.mockRejectedValueOnce(new Error('reply lost')) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(setOption).toHaveBeenCalledTimes(1) + }) +}) + +describe('restart', () => { + /** A restarted process: the same directories, a new store and a new host over + * them. Every lease loads unreconciled, so this is the state that decides + * whether a persisted session is reachable at all. */ + async function reboot( + probeOwner: (record: AgentSessionRecord) => Promise + ) { + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-b', + probeOwner, + now: () => NOW + }) + } + + /** The refusal a restarted host owes a client holding the dead generation's + * fence: stale, with the live fence attached so the retry can succeed. */ + async function staleFenceFrom(held: number): Promise { + const refused = await host.attach(CALLER, ensureParams(held)) + if (refused.ok) { + throw new Error('a fence from the previous host generation was accepted') + } + expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') + const current = refused.refusal.currentFence + expect(current).toBeGreaterThan(held) + return current ?? 0 + } + + it('adjudicates the leases it loaded before deciding who may write', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'pid-absent' })) + + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) + }) + + it('restores durable journals for read-only history without acquiring a provider', async () => { + await attach() + const body = hostTestMessage('persisted conversation') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) + acquire.mockClear() + const listRecords = vi.spyOn(store, 'listRecords') + + await host.restoreReadableSessions() + const restoreReads = listRecords.mock.calls.length + await host.restoreReadableSessions() + + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } + ]) + const history = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(history.ok && history.page.items).not.toHaveLength(0) + expect(acquire).not.toHaveBeenCalled() + expect(listRecords).toHaveBeenCalledTimes(restoreReads) + }) + + it('clears stale TUI recovery at restart, and reacquires the native owner when a surface holds it', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + runtimeKind: 'tui', + handoffStage: 'manual-recovery' + } + })) + await reboot(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + + await host.restoreReadableSessions() + // The recovery stage clears on evidence at startup; the child comes back only once a surface + // holds the session (see structured-agent-session-surface-lifetime.test.ts). + await host.hold(SESSION, 'surface-1') + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + }) + + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + await attach() + const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + + expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + }) + + it('does not remember a failed adjudication as done', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + const probe = vi + .fn<(record: AgentSessionRecord) => Promise>() + .mockRejectedValueOnce(new Error('probe exploded')) + .mockResolvedValue({ outcome: 'pid-absent' }) + await reboot(probe) + + await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('subscribe', () => { + it('opens with a snapshot and then streams cursor-qualified batches', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + const dispose = host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = hostTestMessage('add a retry') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(events[0]?.type).toBe('snapshot') + const batches = events.filter((event) => event.type === 'batch') + expect(batches.length).toBeGreaterThan(0) + const last = batches.at(-1) + expect(last?.type === 'batch' && last.batch.cursor.sequence).toBeGreaterThan(0) + + dispose() + expect(events.at(-1)?.type).toBe('end') + }) + + it('resumes from a client cursor with only the rows it missed', async () => { + await attach() + const body = hostTestMessage('add a retry') + const first = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!first.ok) { + throw new Error(`expected a send, got ${first.refusal.code}`) + } + + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-2', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: first.cursor + }) + expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } }) + + const second = hostTestMessage('and a timeout') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(events.some((event) => event.type === 'snapshot')).toBe(false) + }) + + it('drops a failed transport without aborting the mutation or other subscribers', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'dead-sub', + sessionId: SESSION, + emit: () => { + throw new Error('socket closed') + } + }) + host.subscribe({ + id: 'live-sub', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = hostTestMessage('survive subscriber failure') + + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(events.some((event) => event.type === 'batch')).toBe(true) + }) + + it('resets a subscriber whose epoch is gone', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-3', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } + }) + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + }) + + it('publishes the replacement fence when the owner generation changes', async () => { + const record = await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-4', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + if (!replacement.ok) { + throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) + } + expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: replacement.fence }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts new file mode 100644 index 00000000000..47276ea1ba0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -0,0 +1,299 @@ +// Structured agent-session host: where the lease, journal, and provider adapter meet. +// Mutations share one durable admission path and serialize per session. + +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionAttachResult, + AgentSessionHistoryRequest, + AgentSessionHistoryResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionOptionsResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + AgentSessionSubscribers, + type AgentSessionSubscribeInput +} from './structured-agent-session-subscribers' +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import * as providerSupport from './structured-agent-session-provider-support' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { + createStructuredAgentSessionHostHandoff, + refreshRecoverableStructuredHandoffStatus, + type StructuredAgentSessionHostHandoff +} from './structured-agent-session-host-handoff' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { + createStructuredAgentSessionHolds, + evictHeldStructuredAgentSession, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionHolds, + StructuredAgentSessionHoldOptions +} from './structured-agent-session-holds' +import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' +import { + cancelStructuredAgentSessionTurn, + readStructuredAgentSessionOptions, + respondToStructuredAgentSessionPrompt, + sendStructuredAgentSessionTurn, + setStructuredAgentSessionOption, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' +export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' + +export class StructuredAgentSessionHost { + private readonly sessions = new Map() + private readonly subscribers = new AgentSessionSubscribers() + private readonly tasks = new StructuredAgentSessionTaskQueue() + private readonly runtimeState: StructuredAgentSessionHostRuntimeState + private readonly reconcileLeases: (sessionId: string) => Promise + private readonly handoffs: StructuredAgentSessionHostHandoff + private readonly readableRestorer: StructuredAgentSessionReadableRestorer + private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate() + private readonly holds: StructuredAgentSessionHolds + + constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.runtimeState = new StructuredAgentSessionHostRuntimeState( + deps, + (record) => this.restoreRenewedHandoff(record.sessionId), + (record, probe) => + this.sessions.has(record.sessionId) + ? this.serialize(record.sessionId, () => + this.handoffs.recoverDeadTuiOwner(record.sessionId, record.lease.runtimeFence, probe) + ) + : Promise.resolve() + ) + this.reconcileLeases = createRestartReconciler({ + store: deps.store, + probe: (record) => this.runtimeState.probeRecord(record), + ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + now: () => this.now() + }) + this.handoffs = createStructuredAgentSessionHostHandoff(deps, { + session: (sessionId) => this.requireSession(sessionId), + eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId), + flush: (sessionId) => this.flushStreamedEvents(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + subscribers: this.subscribers, + now: this.now + }) + this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { + resume: (sessionId) => this.resumeForHold(sessionId), + evict: (sessionId) => this.close(sessionId) + }) + this.readableRestorer = new StructuredAgentSessionReadableRestorer({ + store: deps.store, + journalRoot: deps.journalRoot, + supportsRecord: (record) => providerSupport.adapterSupportsRecord(deps.adapter, record), + reconcile: this.reconcileLeases, + resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + hasSession: (sessionId) => this.sessions.has(sessionId), + onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) + }) + this.runtimeState.startLeaseRenewal() + } + + private now = (): number => this.deps.now?.() ?? Date.now() + + hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + + /** A surface bound to this session and wants it live. The FIRST hold on a session with no + * provider child is what resumes one; a retained hold (a subscription) only keeps it. */ + hold = ( + sessionId: string, + holderId: string, + options?: StructuredAgentSessionHoldOptions + ): Promise => this.holds.hold(sessionId, holderId, options) + + /** That surface is gone. The child outlives it by the release grace, and by any running turn. */ + release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) + + isHeld = (sessionId: string): boolean => this.holds.isHeld(sessionId) + + private async resumeForHold(sessionId: string): Promise { + const unreconciled = await this.reconcileLeases(sessionId) + if (unreconciled) { + throw new Error(unreconciled.code) + } + await this.runtimeState.resolveRecovery(sessionId) + await resumeHeldStructuredAgentSession({ + sessionId, + deps: this.deps, + now: () => this.now(), + attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) + }) + } + + private lifetimeContext(): StructuredAgentSessionLifetimeContext { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + now: () => this.now() + } + } + + /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ + private attachContext(): StructuredAgentSessionAttachContext { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + subscribers: this.subscribers, + tasks: this.tasks, + reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + } + } + + /** Releases a session's resources without ending the conversation: the record and journal stay + * on disk, so the same session can be attached again. */ + close(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + await this.handoffs.closeRetainedTuiOwner(sessionId) + await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) + // Whoever asked for the close, the surfaces that were holding this session are looking at a + // session that no longer exists. A failed eviction throws above and keeps them. + this.holds.forget(sessionId) + }) + } + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => + providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) + + listSessionTabs() { + return listStructuredAgentSessionTabs(this.sessions) + } + + reconcileRestartLeases = async (): Promise => { + const refusal = await this.reconcileLeases('startup') + if (refusal) { + throw new Error(refusal.code) + } + } + + restoreReadableSessions = (sessionIds?: readonly string[]): Promise => + this.restartRestore.run(() => this.readableRestorer.restore(sessionIds)) + + private serialize = (sessionId: string, task: () => Promise): Promise => + this.tasks.serialize(sessionId, task) + + private restoreRenewedHandoff(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + if (this.sessions.has(sessionId)) { + await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + } + }) + } + + attach( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams + ): Promise> { + return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) + } + + flushStreamedEvents = (sessionId: string): Promise => + this.runtimeState.flushEventSink(sessionId) + + async flushAllStreamedEvents(): Promise { + this.holds.dispose() + this.runtimeState.stopLeaseRenewal() + this.handoffs.stopTuiHistoryCatchup() + await this.tasks.drainAttaches() + await this.runtimeState.flushAllEventSinks() + } + + private mutationContext(): StructuredAgentSessionMutationContext { + return { + deps: this.deps, + sessions: this.sessions, + publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + requireSession: (sessionId) => this.requireSession(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + } + } + + send = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + sendStructuredAgentSessionTurn(this.mutationContext(), caller, params) + + cancel = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + cancelStructuredAgentSessionTurn(this.mutationContext(), caller, params) + + respondToPrompt = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + respondToStructuredAgentSessionPrompt(this.mutationContext(), caller, params) + + setOption = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + setStructuredAgentSessionOption(this.mutationContext(), caller, params) + + readOptions = (sessionId: string): Promise => + readStructuredAgentSessionOptions(this.mutationContext(), sessionId) + + async handoffStatus(sessionId: string): Promise { + this.requireSession(sessionId) + return this.serialize(sessionId, () => + refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + ) + } + + history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + return readStructuredAgentSessionHistoryResult({ + journal: this.requireSession(request.sessionId).journal, + record: this.deps.store.getRecord(request.sessionId), + request + }) + } + + subscribe(input: AgentSessionSubscribeInput): () => void { + const session = this.requireSession(input.sessionId) + const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 + return this.subscribers.open({ + ...input, + journal: session.journal, + fence, + handoff: this.handoffs.status(input.sessionId) + }) + } + + unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) + + private requireSession(sessionId: string): StructuredAgentSessionHostSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + } + return session + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts new file mode 100644 index 00000000000..c3c89fbf09d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' + +describe('pinnedAgentSessionLaunchEnv', () => { + it('layers the pinned account home over the shell environment', async () => { + await expect( + pinnedAgentSessionLaunchEnv( + async () => ({ EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }), + hostTestAttachParams(null) + ) + ).resolves.toEqual({ + launchEnv: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + } + }) + }) + + it('copies the host-resolved provider arguments into reservation authority', async () => { + await expect( + pinnedAgentSessionLaunchArgs(async () => ['--profile', 'review'], hostTestAttachParams(null)) + ).resolves.toEqual({ launchArgs: ['--profile', 'review'] }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts new file mode 100644 index 00000000000..5ce67d7f4df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -0,0 +1,33 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +type LaunchEnvResolver = ( + provider: AgentSessionRecord['provider'] +) => Promise | undefined> | Record | undefined + +type LaunchArgsResolver = ( + provider: AgentSessionRecord['provider'] +) => Promise | string[] | undefined + +export async function pinnedAgentSessionLaunchEnv( + resolver: LaunchEnvResolver | undefined, + params: AgentSessionAttachParams +): Promise<{ launchEnv: Record } | Record> { + if (!resolver) { + return {} + } + return { + launchEnv: { + ...(await resolver(params.provider)), + [params.accountHome.variable]: params.accountHome.path + } + } +} + +export async function pinnedAgentSessionLaunchArgs( + resolver: LaunchArgsResolver | undefined, + params: AgentSessionAttachParams +): Promise<{ launchArgs: string[] } | Record> { + const launchArgs = await resolver?.(params.provider) + return launchArgs ? { launchArgs: [...launchArgs] } : {} +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts new file mode 100644 index 00000000000..63d29754f61 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -0,0 +1,32 @@ +// Handing the durable lease back after eviction stopped this host's child. +// +// Guarded on `hasProviderChild` for a reason that is not bookkeeping: a session restored only for +// reading, or one a TUI owns, names an owner process this host never started and may still be +// alive. Writing `exit-observed` against that record would release a lease out from under a running +// process and let a second writer in. + +import { + isSurfaceReleasableAgentSessionRecord, + releaseStoredAgentSessionOwnerAfterSurfaceClose +} from '../../runtime/agent-session-surface-release-transition' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +export async function releaseStoredStructuredAgentSessionOwner(input: { + store: AgentSessionRecordStore + sessionId: string + hasProviderChild: boolean + now: number +}): Promise { + if (!input.hasProviderChild) { + return + } + const record = input.store.getRecord(input.sessionId) + if (!record || !isSurfaceReleasableAgentSessionRecord(record)) { + return + } + await releaseStoredAgentSessionOwnerAfterSurfaceClose(input.store, { + sessionId: input.sessionId, + expectedFence: record.lease.runtimeFence, + now: input.now + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts new file mode 100644 index 00000000000..e5b97dea189 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -0,0 +1,292 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' + +const NOW = 1_800_000_000_000 +const roots: string[] = [] + +async function liveStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: 'session-renewal', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: root }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-renewal', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-renewal' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'link-renewal', + handle: { provider: 'codex', threadId: 'thread-renewal' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + return store +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured agent-session lease renewal', () => { + it('isolates renewal failures per live record', async () => { + const records = ['a', 'b'].map((suffix, index) => { + const sessionId = `session-${suffix}` + return agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeKind: 'native', + runtimeFence: index + 1, + ownerProcess: { + hostId: 'local', + pid: 4200 + index, + processStartTimeMs: NOW - 1_000, + spawnToken: `spawn-${suffix}` + }, + reservedSpawnToken: `spawn-${suffix}`, + lastRenewedAt: NOW, + leaseDeadlineAt: NOW + 30_000 + }) + ) + }) + const renewLeases = vi.fn(async (renewals: readonly { sessionId: string }[]) => + renewals.map((renewal) => records.find((record) => record.sessionId === renewal.sessionId)!) + ) + const probeMany = vi.fn( + async () => + new Map( + records.map((record) => [ + record.sessionId, + { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const + ]) + ) + ) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store: { listRecords: () => records, renewLeases } as unknown as AgentSessionRecordStore, + probe: vi.fn(), + probeMany, + now: () => NOW + 10_000 + }) + + await renewer.renewNow() + + expect(probeMany).toHaveBeenCalledOnce() + expect(renewLeases).toHaveBeenCalledOnce() + expect(renewLeases.mock.calls[0]?.[0]).toHaveLength(2) + }) + + it('keeps a healthy lease alive when a sibling renewal is superseded', async () => { + const records = ['a', 'b'].map((suffix, index) => + agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: `session-${suffix}`, + runtimeKind: 'native', + runtimeFence: index + 1, + ownerProcess: { + hostId: 'local', + pid: 4200 + index, + processStartTimeMs: NOW - 1_000, + spawnToken: `spawn-${suffix}` + }, + reservedSpawnToken: `spawn-${suffix}`, + lastRenewedAt: NOW, + leaseDeadlineAt: NOW + 30_000 + }) + ) + ) + const renewLeases = vi.fn(async () => { + throw new Error('agent_session_checkpoint_stale') + }) + const renewLease = vi.fn(async (renewal: { sessionId: string }) => { + if (renewal.sessionId === 'session-b') { + throw new Error('agent_session_checkpoint_stale') + } + return records[0]! + }) + const onRenewed = vi.fn() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store: { + listRecords: () => records, + renewLeases, + renewLease + } as unknown as AgentSessionRecordStore, + probe: async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['spawn-token' as const] + }), + now: () => NOW + 10_000, + onRenewed, + onError + }) + + await renewer.renewNow() + + expect(renewLeases).toHaveBeenCalledOnce() + expect(onRenewed).toHaveBeenCalledOnce() + expect(renewLease).toHaveBeenCalledTimes(2) + expect(onRenewed).toHaveBeenCalledWith(records[0]) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-b', + error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' }) + }) + }) + + it('drives renewal on the production interval', async () => { + vi.useFakeTimers() + const store = await liveStore() + let now = NOW + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + now: () => now + }) + try { + renewer.start() + now += 10_000 + await vi.advanceTimersByTimeAsync(10_000) + await vi.waitFor(() => + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now) + ) + } finally { + renewer.stop() + vi.useRealTimers() + } + }) + + it('renews every live owner only after re-proving its child identity', async () => { + const store = await liveStore() + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const onRenewed = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000, + onRenewed + }) + + await renewer.renewNow() + + expect(probe).toHaveBeenCalledOnce() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + expect(onRenewed).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }) + ) + }) + + it('stops extending the lease when child proof is no longer sufficient', async () => { + const store = await liveStore() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), + now: () => NOW + 10_000, + onError + }) + + await renewer.renewNow() + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-renewal', + error: expect.any(Error) + }) + }) + + it('never extends the lease of a native record parked in recovery', async () => { + // The host cannot vouch for a native child it holds no transport to; renewing while + // recovering keeps an orphan pid's lease alive and reads as a healthy owner. + const store = await liveStore() + await store.transitionHandoff('session-renewal', (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: 'recovering' } + })) + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000 + }) + + await renewer.renewNow() + + expect(probe).not.toHaveBeenCalled() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + }) + + it('routes a proven dead TUI owner into handoff recovery', async () => { + const store = await liveStore() + await store.transitionHandoff('session-renewal', (record) => ({ + ...record, + lease: { ...record.lease, runtimeKind: 'tui' } + })) + const onDeadTuiOwner = vi.fn(async () => undefined) + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + 10_000, + onDeadTuiOwner, + onError + }) + + await renewer.renewNow() + + expect(onDeadTuiOwner).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }), + { outcome: 'pid-absent' } + ) + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts new file mode 100644 index 00000000000..e31ea8c95a7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -0,0 +1,156 @@ +import { + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from '../../runtime/agent-session-record-store' + +const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) + +export class StructuredAgentSessionLeaseRenewer { + private timer: ReturnType | null = null + private running = false + + constructor( + private readonly input: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number + onRenewed?: (record: AgentSessionRecord) => Promise + onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + intervalMs?: number + } + ) {} + + start(): void { + if (this.timer) { + return + } + this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + async renewNow(): Promise { + if (this.running) { + return + } + this.running = true + try { + const records = this.input.store.listRecords().filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + // A native record parked in recovery has no transport the host can vouch + // for; renewing it keeps an orphan pid's lease reading as a healthy owner. + !( + record.lease.runtimeKind === 'native' && + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') + ) + ) + const probes = await this.probe(records) + const renewals: { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + }[] = [] + const now = this.input.now() + for (const record of records) { + const probe = probes.get(record.sessionId) + if (!probe) { + continue + } + if ( + record.lease.runtimeKind === 'tui' && + isProvenDeadProbe(probe) && + this.input.onDeadTuiOwner + ) { + try { + await this.input.onDeadTuiOwner(record, probe) + } catch (error) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + continue + } + renewals.push({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: probe, + now + }) + } + // The store persists the whole record file per transaction, so keep the healthy path to + // one commit. If one renewal is superseded, retrying individually preserves isolation. + let results: PromiseSettledResult[] + try { + const renewed = await this.input.store.renewLeases(renewals) + await Promise.all( + renewed.map(async (record) => { + await this.input.onRenewed?.(record) + }) + ) + results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) + } catch { + results = await Promise.allSettled( + renewals.map(async (renewal) => { + const renewed = await this.input.store.renewLease(renewal) + await this.input.onRenewed?.(renewed) + return renewed + }) + ) + } + results.forEach((result, index) => { + if (result.status === 'rejected') { + const renewal = renewals[index] + if (renewal) { + this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + } + } + }) + } finally { + this.running = false + } + } + + private async probe( + records: readonly AgentSessionRecord[] + ): Promise> { + try { + if (this.input.probeMany) { + return await this.input.probeMany(records) + } + const settled = await Promise.allSettled(records.map((record) => this.input.probe(record))) + const probes = new Map() + for (const [index, result] of settled.entries()) { + const record = records[index] + if (result.status === 'fulfilled') { + probes.set(record.sessionId, result.value) + } else { + this.input.onError?.({ sessionId: record.sessionId, error: result.reason }) + } + } + return probes + } catch (error) { + for (const record of records) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + return new Map() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts new file mode 100644 index 00000000000..90b68194c4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts @@ -0,0 +1,246 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-live-tui-restart' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured session live TUI restart survival', () => { + it('does not stop a daemon-owned toggle TUI when restart adoption is unavailable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-live-tui-restart-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'toggle-tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000000`, + fingerprint: 'create' + }, + now: NOW + }) + const process = { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'toggle-tui-spawn' + } + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + process, + now: NOW + }) + const record = await store.proveOwner({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'toggle-tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + const stopRecoveredOwner = vi.fn(async () => undefined) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + recoverTuiOwner: vi.fn(async () => ({ + terminal: { + handle: 'term-toggle', + tabId: 'tab-toggle', + paneKey: 'tab-toggle:leaf-toggle', + ptyId: 'pty-toggle' + }, + process, + link: record.providerHandleChain.at(-1)! + })), + reproveTuiOwner: vi.fn(async () => { + throw new Error('The owning terminal is not hydrated yet.') + }), + probeRecoveredOwner: async () => 'live', + stopRecoveredOwner, + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'busy' + }, + session: vi.fn() as never, + suspendNative: vi.fn(), + acquireNative: vi.fn(), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: 'manual-recovery', + ownerProcess: process + }) + }) + + it('persists the provider leaf returned by Claude re-proof before clearing recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-restart-')) + roots.push(root) + const sessionId = 'session-claude-live-tui-restart' + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'claude-tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + const process = { + hostId: 'local', + pid: 4201, + processStartTimeMs: NOW - 1_000, + spawnToken: 'claude-tui-spawn' + } + await store.commitProcessIdentity({ + sessionId, + fence: reserved.record.lease.runtimeFence, + process, + now: NOW + }) + const record = await store.proveOwner({ + sessionId, + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'claude-created-link', + handle: { provider: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-before' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + + const reproofed = { + terminal: { + handle: 'term-claude', + tabId: 'tab-claude', + paneKey: 'tab-claude:leaf-claude', + ptyId: 'pty-claude' + }, + process, + link: { + linkId: 'claude-resumed-link', + handle: { + provider: 'claude' as const, + sessionId: 'claude-session', + leafUuid: 'leaf-after' + }, + origin: 'resumed' as const, + mintedAtFence: record.lease.runtimeFence, + observedAt: NOW + 1 + }, + transcriptPath: join(root, 'claude-home', 'projects', 'session.jsonl') + } + const persistTuiProviderHandle = vi.fn(async () => undefined) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + recoverTuiOwner: vi.fn(async () => ({ + ...reproofed, + link: record.providerHandleChain.at(-1)! + })), + reproveTuiOwner: vi.fn(async () => reproofed), + probeRecoveredOwner: async () => 'live', + stopRecoveredOwner: vi.fn(), + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'idle' + }, + persistTuiProviderHandle, + session: vi.fn() as never, + suspendNative: vi.fn(), + acquireNative: vi.fn(), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + + await coordinator.restore(sessionId) + + expect(persistTuiProviderHandle).toHaveBeenCalledWith({ + sessionId, + link: reproofed.link, + now: NOW + }) + expect(coordinator.status(sessionId)).toMatchObject({ owner: 'tui', phase: 'idle' }) + expect(store.getRecord(sessionId)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts new file mode 100644 index 00000000000..18298f28892 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -0,0 +1,148 @@ +// The one route every mutating agent-session call takes: recompute the +// fingerprint, admit through the durable operation ledger, check the lease, then +// run the plan. It lives outside the host so that no method can quietly grow its +// own admission rules by sitting next to the call site. + +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { + code: 'agent_session_ownership_unknown', + message: 'This host holds no attached session by that id.' +} + +export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { ok: false, refusal } +} + +export type AgentSessionMutationRequest = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + /** Journal of the attached session; absent when this host holds none. */ + journal: AgentSessionJournal | undefined + publish: (journal: AgentSessionJournal) => void + now: () => number +} + +export async function admitAndRunAgentSessionMutation( + request: AgentSessionMutationRequest +): Promise> { + const { envelope, plan, journal } = request + const record = request.store.getRecord(envelope.sessionId) + if (!journal || !record) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return refuseAgentSessionMutation(conflict) + } + const admission = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: await request.store.admitOperation({ + callerKey: request.callerKey, + operationId: envelope.clientOperationId, + fingerprint: hostFingerprint, + now: request.now() + }), + lease: record.lease + }) + if (admission.decision === 'refused') { + return refuseAgentSessionMutation(admission.refusal) + } + + const fence = record.lease.runtimeFence + const context = turnContext(request, journal, fence) + if (admission.decision === 'replay') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: admission.row.outcome, + reconstruct: () => plan.replay(context, admission.row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context) + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + if (replay.decision === 'replay') { + return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + } + // Nothing durable landed, so this id is about to run for the first time. A + // refused call leaves its ledger row behind, and replaying past the lease and + // the fence would let a resend act under an owner that has since changed — so + // a first run pays the full admission price either way. + const rerun = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: { decision: 'admit', row: admission.row }, + lease: record.lease + }) + if (rerun.decision === 'refused') { + return refuseAgentSessionMutation(rerun.refusal) + } + } + + plan.beforeRun?.() + const outcome = await runSettledAgentSessionMutation({ + store: request.store, + callerKey: request.callerKey, + envelope, + plan, + context + }) + return outcome.ok + ? { ok: true, replayed: false, fence, cursor: journal.cursor(), value: outcome.value } + : refuseAgentSessionMutation(outcome.refusal) +} + +function turnContext( + request: AgentSessionMutationRequest, + journal: AgentSessionJournal, + fence: number +): AgentSessionTurnContext { + const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + return { + sessionId: request.envelope.sessionId, + journal, + fence, + adapter: request.adapter, + ...(persistedOptions ? { persistedOptions } : {}), + persistOptions: (options) => + request.store + .replaceSessionOptions({ + sessionId: request.envelope.sessionId, + fence, + options, + now: request.now() + }) + .then(() => undefined), + resolvedBy: request.callerKey, + publish: () => request.publish(journal), + now: () => request.now() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts new file mode 100644 index 00000000000..99835da7cea --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -0,0 +1,139 @@ +// One plan per mutating method: what it fingerprints, what it does, and how its +// answer is rebuilt on a replay. +// +// The replay half matters more than it looks. The ledger records only that an +// operation happened, so the durable answer has to come back out of the journal. +// A plan that cannot find its effect returns null, and the call runs for real — +// which is exactly right when the crash landed before the journal write. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { + performCancel, + performPrompt, + performSend, + performSetOption, + type AgentSessionTurnContext, + type TurnOutcome +} from './structured-agent-session-turns' + +export type MutationPlan = { + method: string + fields: Record + beforeRun?: () => void + run: (ctx: AgentSessionTurnContext) => Promise> + replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null + rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean +} + +export function sendPlan(params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + retryUnknown?: true + beforeRun?: () => void +}): MutationPlan { + // The operation id IS the client message id: one send, one durable row, one + // key the client reconciles its optimistic bubble against. + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.send', + // A control signal is not payload; only the matching durable unknown unlocks redispatch. + fields: { body: params.body }, + ...(params.beforeRun ? { beforeRun: params.beforeRun } : {}), + rerunWhenReplayMissing: (ctx) => + params.retryUnknown === true && + ctx.journal + .submissions() + .some( + (entry) => entry.clientMessageId === clientMessageId && entry.dispatchState === 'unknown' + ), + run: (ctx) => + performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: params.body, + retryUnknown: params.retryUnknown + }), + replay: (ctx) => { + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + return submission && !(params.retryUnknown && submission.dispatchState === 'unknown') + ? { clientMessageId, submission } + : null + } + } +} + +export function cancelPlan(params: { + envelope: AgentSessionMutationEnvelope + turnId: string +}): MutationPlan { + return { + method: 'agentSession.cancel', + fields: { turnId: params.turnId }, + run: (ctx) => + performCancel(ctx, { + clientOperationId: params.envelope.clientOperationId, + turnId: params.turnId + }), + // Interrupting twice would kill a turn the client never asked to stop, so a + // replay reports the turn as already handled instead. + replay: () => ({ turnId: params.turnId, cancelled: false }) + } +} + +export function promptPlan(params: { + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string +}): MutationPlan { + return { + method: `agentSession.respondTo:${params.kind}`, + fields: { + itemId: params.itemId, + expectedRevision: params.expectedRevision, + optionId: params.optionId + }, + run: (ctx) => performPrompt(ctx, params), + replay: (ctx) => { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === params.itemId) + const body = item?.body + if (!item || !body || (body.kind !== 'approval' && body.kind !== 'question')) { + return null + } + return body.resolution.state === 'pending' + ? null + : { itemId: item.itemId, revision: item.revision, resolution: body.resolution } + } + } +} + +export function setOptionPlan(params: { + key: string + value: string +}): MutationPlan { + return { + method: 'agentSession.setOption', + fields: { key: params.key, value: params.value }, + run: (ctx) => performSetOption(ctx, params), + // A pending row may have crashed before the adapter call. Reapplying the + // same assignment is safe; only a settled success can be answered directly. + replay: (ctx, outcome) => + outcome.status === 'succeeded' + ? { + key: params.key, + value: params.value, + ...(ctx.persistedOptions ? { options: { ...ctx.persistedOptions } } : {}) + } + : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts new file mode 100644 index 00000000000..4cb24518c69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -0,0 +1,33 @@ +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runSettledAgentSessionMutation(input: { + store: AgentSessionRecordStore + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + context: AgentSessionTurnContext +}): Promise> { + const settle = ( + outcome: Parameters[0]['outcome'] + ) => + input.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.envelope.clientOperationId, + outcome + }) + try { + const outcome = await input.plan.run(input.context) + await settle( + outcome.ok + ? { status: 'succeeded', sessionId: input.envelope.sessionId } + : { status: 'failed', code: outcome.refusal.code } + ) + return outcome + } catch (error) { + await settle({ status: 'unknown' }) + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts new file mode 100644 index 00000000000..607db1e6c4b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -0,0 +1,13 @@ +/** Option validation failed before the provider session was mutated. */ +export class AgentSessionOptionRejectedError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionOptionRejectedError' + } +} + +export function isAgentSessionOptionRejectedError( + error: unknown +): error is AgentSessionOptionRejectedError { + return error instanceof Error && error.name === 'AgentSessionOptionRejectedError' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts new file mode 100644 index 00000000000..b9ba03ff327 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -0,0 +1,20 @@ +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export async function readNativeSessionOptions(input: { + adapter: Pick + sessionId: string + fence: number + priorOptions?: Readonly> +}): Promise> | undefined> { + const { adapter, sessionId, fence, priorOptions } = input + const reported = await adapter.readOptions?.({ sessionId, fence }) + if (!reported) { + return undefined + } + const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + return { + ...restored, + model: reported.current.model, + ...(reported.current.effort ? { effort: reported.current.effort } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts new file mode 100644 index 00000000000..41054d10ece --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -0,0 +1,266 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const DEFAULT_MODEL = 'gpt-default' +const PICKED_MODEL = 'gpt-picked' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let router: StructuredAgentSessionAdapter +let acquire: Mock +let closeNativeSession: Mock> +let activeModel: string +let activeEffort: string | null +let transcriptPath: string +let optionFailure: Error | null +let tuiLaunchFailure: Error | null +/** What the adapter's closeSession reports about the child's exit. */ +let closeSessionExit = true +const dispatchedModels: string[] = [] +const launchedOptions: (Readonly> | undefined)[] = [] +const closedTuiOwners: StructuredTuiOwner[] = [] + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { + hostId: 'local', + pid: 5200, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ record, fence, spawnToken }) => { + if (tuiLaunchFailure) { + const error = tuiLaunchFailure + tuiLaunchFailure = null + throw error + } + launchedOptions.push(record.options) + return tuiOwner(fence, spawnToken) + }, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + closeTuiOwner: async (owner) => { + closedTuiOwners.push(owner) + return { transcriptPath: owner.transcriptPath } + }, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + activeEffort = options?.effort ?? null + return { + process: { + hostId: 'local', + pid: 4200 + acquire.mock.calls.length, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `native-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + closeNativeSession = vi.fn(async () => { + activeModel = DEFAULT_MODEL + return closeSessionExit + }) + return { + supportsLocation: () => true, + acquire, + dispatch: vi.fn(async () => { + dispatchedModels.push(activeModel) + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } + }), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ key, value }) => { + if (optionFailure) { + const error = optionFailure + optionFailure = null + throw error + } + if (key === 'model') { + activeModel = value + } else if (key === 'effort') { + activeEffort = value + } + return { + model: activeModel, + ...(activeEffort ? { effort: activeEffort } : {}) + } + }), + readOptions: vi.fn(async () => ({ + current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, + models: [] + })), + closeSession: closeNativeSession + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + activeEffort = null + optionFailure = null + tuiLaunchFailure = null + closeSessionExit = true + dispatchedModels.length = 0 + launchedOptions.length = 0 + closedTuiOwners.length = 0 + const accountHome = join(root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') + transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-12T10:00:00.000Z', + payload: { id: THREAD, session_id: THREAD } + })}\n`, + 'utf8' + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + router = adapter() + host = new StructuredAgentSessionHost({ + store, + adapter: router, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-native', + handoffTransport: handoffTransport(), + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + expect(attached).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session options and close', () => { + it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { + optionFailure = new AgentSessionOptionRejectedError('model list unavailable') + const fields = { key: 'model', value: PICKED_MODEL } + const rejected = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + }) + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + }) + + // Closing a chat used to leave its provider child resident for the whole app session: the host's + // session map had no delete and the only teardown was app quit. + it('stops the provider child and forgets the session when the chat closes', async () => { + expect(host.hasSession(SESSION)).toBe(true) + + await host.close(SESSION) + + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'exit-observed' } + }) + expect(host.hasSession(SESSION)).toBe(false) + + await expect(host.close(SESSION)).resolves.toBeUndefined() + expect(closeNativeSession).toHaveBeenCalledOnce() + }) + + it('is a no-op for a session it does not hold', async () => { + await host.close(SESSION) + closeNativeSession.mockClear() + + await expect(host.close(SESSION)).resolves.toBeUndefined() + expect(closeNativeSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts new file mode 100644 index 00000000000..a1b6b39f5e0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const NEXT_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams( + operationId = OPERATION, + expectedRuntimeFence: number | null = null +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +describe('processless structured session reservation', () => { + it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const adapter = { + acquire: vi.fn(async () => { + throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) + }) + } as unknown as StructuredAgentSessionAdapter + const processlessProof = vi.spyOn(store, 'setReservationProcesslessProof') + const settlement = vi.spyOn(store, 'settleFailedAcquisition') + + await expect( + performAttach({ + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + ).rejects.toThrow('workspace no longer exists') + expect(settlement).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) + ) + // No separate durable proof write: the only proof call is acquisition's single-use clear. + expect(processlessProof).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ processlessAt: null }) + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + runtimeFence: 2, + processlessAt: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } + }) + expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) + + const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), + now: NOW + 1 + }) + expect(reopened.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 2, + reservedSpawnToken: null + }) + }) + + it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const adapter = { + acquire: vi + .fn() + .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: 'link-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })), + releaseAcquisition: vi.fn(async () => true) + } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(adapter.acquire).toHaveBeenCalledOnce() + + await expect( + performAttach({ + ...input, + authority: { + ...input.authority, + spawnToken: 'spawn-b', + handoffOperationId: NEXT_OPERATION + }, + params: attachParams(NEXT_OPERATION, 2) + }) + ).resolves.toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3, + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts new file mode 100644 index 00000000000..cfcae081b88 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CLAUDE_SESSION = 'claude-session' +const hosts: StructuredAgentSessionHost[] = [] +let root = '' + +function claudeAdapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'claude', + acquire: async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + }), + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } +} + +function createHost( + store: AgentSessionRecordStore, + probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] +): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + store, + adapter: claudeAdapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner, + now: () => HOST_TEST_NOW + }) + hosts.push(host) + return host +} + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) + await rm(root, { recursive: true, force: true }) + root = '' +}) + +describe('structured session provider restore', () => { + it('restores a durable Claude session tab with its recorded provider', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) + resetHostTestOperationIds() + const storeDirectory = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const host = createHost(store) + const attached = await host.attach( + { callerKey: 'client-1' }, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + + const reopenedStore = await AgentSessionRecordStore.open({ + directory: storeDirectory, + hostId: 'local' + }) + const restarted = createHost(reopenedStore, async () => ({ + outcome: 'indeterminate', + reason: 'read does not need ownership' + })) + + await restarted.restoreReadableSessions() + + expect(restarted.listSessionTabs()).toEqual([ + { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts new file mode 100644 index 00000000000..99958a2bcb0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -0,0 +1,25 @@ +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export function adapterSupportsCreate( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + return ( + adapter.supportsCreate?.(location, agent) ?? + (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) + ) +} + +export function adapterSupportsRecord( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord +): boolean { + return adapter.supportsCreate + ? adapter.supportsCreate(record.location, record.provider) + : record.provider === 'codex' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts new file mode 100644 index 00000000000..3b4ffa95e54 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -0,0 +1,88 @@ +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { loadJournal } from '../agent-session-journal/journal-open' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { + attachFingerprintFields, + journalIdentityFor, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' + +export type RestoredStructuredAgentSessionRead = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number + hasProviderChild: false +} + +export async function restoreStructuredAgentSessionRead( + store: AgentSessionRecordStore, + journalRoot: string, + sessionId: string +): Promise { + const record = store.getRecord(sessionId) + if (!record) { + return null + } + const params = attachParamsForRecord(record, { + clientOperationId: `read-restore:${record.sessionId}`, + expectedRuntimeFence: record.lease.runtimeFence + }) + const journalDir = journalDirectoryFor(journalRoot, { + workspaceId: record.location.workspaceId, + sessionId + }) + const loaded = await loadJournal(journalDir, sessionId) + if (!loaded || loaded.corrupt) { + return null + } + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + journalDir, + loaded + }) + // Read restore opens the journal and nothing else: no adapter call, so no provider child. + return { journal, params, fence: record.lease.runtimeFence, hasProviderChild: false } +} + +export function attachParamsForRecord( + record: AgentSessionRecord, + input: { + clientOperationId: string + expectedRuntimeFence: number + runtimeKind?: AgentSessionOwnerRuntimeKind + } +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: record.sessionId, + clientOperationId: input.clientOperationId, + expectedRuntimeFence: input.expectedRuntimeFence, + payloadFingerprint: '' + }, + location: record.location, + provider: record.provider, + agent: record.provider, + accountHome: record.accountHome, + runtimeKind: input.runtimeKind ?? record.lease.runtimeKind + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: record.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts new file mode 100644 index 00000000000..8859365b117 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) + +vi.mock('./structured-agent-session-restart-restore', () => ({ + restoreStructuredAgentSessionsOnRestart: restoreOnRestart +})) + +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' + +describe('StructuredAgentSessionReadableRestorer', () => { + beforeEach(() => { + restoreOnRestart.mockReset().mockResolvedValue(undefined) + }) + + it('passes targeted records to the restore pool in visible-first order', async () => { + const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map( + (sessionId) => ({ sessionId }) as AgentSessionRecord + ) + const restorer = new StructuredAgentSessionReadableRestorer({ + store: { listRecords: () => records } as never, + journalRoot: '/tmp/journals', + supportsRecord: () => true, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined, + restoreHandoff: async () => undefined + }) + + await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) + + expect(restoreOnRestart).toHaveBeenCalledOnce() + expect(restoreOnRestart.mock.calls[0][0].records.map((record) => record.sessionId)).toEqual([ + 'visible-a', + 'visible-b', + 'background-a', + 'background-b' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts new file mode 100644 index 00000000000..f4e09509b07 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -0,0 +1,52 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { RestoredStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +export class StructuredAgentSessionReadableRestorer { + private restorePromise: Promise | null = null + + constructor( + private readonly input: { + store: AgentSessionRecordStore + journalRoot: string + supportsRecord: (record: AgentSessionRecord) => boolean + reconcile: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + hasSession: (sessionId: string) => boolean + onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise + } + ) {} + + restore(sessionIds?: readonly string[]): Promise { + this.restorePromise ??= this.restoreReadableSessions(sessionIds).catch((error: unknown) => { + this.restorePromise = null + throw error + }) + return this.restorePromise + } + + private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { + const targetOrder = sessionIds + ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) + : null + const records = this.input.store + .listRecords() + .filter( + (record) => + this.input.supportsRecord(record) && (!targetOrder || targetOrder.has(record.sessionId)) + ) + if (targetOrder) { + records.sort( + (left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)! + ) + } + await restoreStructuredAgentSessionsOnRestart({ + ...this.input, + records + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts new file mode 100644 index 00000000000..a5927dc0c14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -0,0 +1,344 @@ +// End-to-end exits from latched recovery states: no shape a user cannot get out of. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' +import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-runtime' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +const spawnedOwners = new Set>() +const supersededHosts = new Set() + +async function spawnOwner(spawnToken: string) { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', 'setInterval(() => {}, 1_000)'], + env: { ...process.env, [CODEX_SPAWN_TOKEN_ENV]: spawnToken } + }) + spawnedOwners.add(child) + const pid = child.pid + if (!pid) { + throw new Error('owner process did not start') + } + const processStartTimeMs = await readProcessStartTimeMs(pid) + if (processStartTimeMs === null) { + throw new Error('owner process start time was unavailable') + } + return { + child, + process: { hostId: 'local', pid, processStartTimeMs, spawnToken } + } +} + +async function stopOwner(child: ReturnType): Promise { + if (child.exitCode === null && child.signalCode === null) { + const closed = new Promise((resolve) => child.once('close', () => resolve())) + child.kill('SIGTERM') + await closed + } + spawnedOwners.delete(child) +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition: vi.fn(async () => undefined), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter +} + +function openHost(overrides: Partial = {}): void { + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...overrides + }) +} + +async function reopenStore(): Promise { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await Promise.all([...supersededHosts].map((superseded) => superseded.flushAllStreamedEvents())) + supersededHosts.clear() + await Promise.all([...spawnedOwners].map((child) => stopOwner(child))) + await rm(root, { recursive: true, force: true }) +}) + +describe('recovery exits', () => { + it('keeps an ownerless unproven acquisition in manual recovery across restart', async () => { + acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) + await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( + 'agent_session_acquisition_exit_unproven' + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery', + handoffOperationId: null, + ownerProcess: null, + runtimeFence: 1, + reservedSpawnToken: 'spawn-a' + }) + + await reopenStore() + openHost({ mintSpawnToken: () => 'spawn-b' }) + + const refused = await host.attach(CALLER, hostTestAttachParams(1)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + expect(acquire).toHaveBeenCalledOnce() + }) + + it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { + // A real session first, so restart restore has a journal to read and runs the + // handoff restorer over the residue instead of skipping the record. + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + // The resume fails at owner proof and cleanup cannot prove exit: the settlement + // keeps the reservation latched at `recovering` with its committed owner identity. + vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(new Error('handle proof lost')) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await expect(host.attach(CALLER, hostTestAttachParams(2))).rejects.toThrow( + 'agent_session_acquisition_exit_unproven' + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + handoffOperationId: null, + ownerProcess: { spawnToken: 'spawn-b' }, + runtimeFence: 3, + reservedSpawnToken: 'spawn-b' + }) + + // The unproven owner dies before the next launch; reconciliation and handoff restore run. + await reopenStore() + openHost({ + mintSpawnToken: () => 'spawn-c', + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await host.restoreReadableSessions() + + // Death proof releases the residue outright: no handoff continuation, no spawned child, + // no stage a user would have to clear by hand. + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + runtimeFence: 4 + }) + + // The ordinary native recovery path remains: the first surface hold resumes it. + await host.hold(SESSION, 'surface-1') + expect(acquire).toHaveBeenCalledTimes(3) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 5, + ownerProcess: { spawnToken: 'spawn-c' } + }) + }) + + it('stops a surviving native child after restart instead of readopting its dead transport', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + let orphanAlive = true + const stopOwnerProcess = vi.fn((_pid: number, _signal: 'SIGTERM' | 'SIGKILL') => { + orphanAlive = false + }) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + + const stale = await host.attach(CALLER, hostTestAttachParams(1)) + expect(stale).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 2 } + }) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + const retried = await host.attach(CALLER, hostTestAttachParams(2)) + expect(retried).toMatchObject({ ok: true }) + // A fresh child was spawned; the orphan pid's lease did not survive as the owner. + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null + }) + }) + + it('heals a stranded native owner during startup restore, and spawns nothing until a surface asks', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + let orphanAlive = true + const stopOwnerProcess = vi.fn(() => { + orphanAlive = false + }) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + + await host.restoreReadableSessions() + + // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but + // nothing has asked to look at this session, so no replacement child exists yet. + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(acquire).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null + }) + + await host.hold(SESSION, 'surface-1') + + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null, + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) + + it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { + const outgoing = await spawnOwner('spawn-a') + acquire.mockResolvedValueOnce({ + process: outgoing.process, + link: { + linkId: 'link-outgoing', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + } + }) + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + + const outgoingHost = host + const outgoingStore = store + supersededHosts.add(outgoingHost) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + const realProbe = createStructuredAgentSessionOwnerProbe('local') + let overlapDriven = false + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async (record) => { + const probe = await realProbe(record) + if (!overlapDriven) { + overlapDriven = true + await outgoingStore.renewLease({ + sessionId: SESSION, + fence: 1, + childProbe: probe, + now: NOW + 1 + }) + await stopOwner(outgoing.child) + } + return probe + } + }) + + await host.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + ownerProcess: null + }) + expect(acquire).toHaveBeenCalledOnce() + + const replacement = await spawnOwner('spawn-b') + acquire.mockResolvedValueOnce({ + process: replacement.process, + link: { + linkId: 'link-replacement', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: 3, + observedAt: NOW + 2 + } + }) + + await host.hold(SESSION, 'surface-overlap') + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null, + ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } + }) + expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts new file mode 100644 index 00000000000..4f83a1855f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -0,0 +1,300 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + resolveStructuredSessionRecovery, + type StructuredSessionRecoveryResolutionDeps +} from './structured-agent-session-recovery-resolution' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-recovery' +const roots: string[] = [] +let operations = 0 + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function openStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-recovery-resolution-')) + roots.push(root) + return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) +} + +async function reserve(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { + operations += 1 + return store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + runtimeKind, + expectedFence: null, + spawnToken: 'spawn-recovery', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-${String(operations).padStart(32, '0')}`, + fingerprint: 'create' + }, + now: NOW + }) +} + +async function liveOwner(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { + const reserved = await reserve(store, runtimeKind) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-recovery' + }, + now: NOW + }) + return store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-recovery', + handle: { provider: 'codex', threadId: 'thread-recovery' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function latch(store: AgentSessionRecordStore, stage: 'recovering' | 'manual-recovery') { + return store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: stage } + })) +} + +function deps( + store: AgentSessionRecordStore, + probe: (calls: number) => AgentSessionOwnerProbe, + overrides: Partial = {} +): StructuredSessionRecoveryResolutionDeps & { probes: () => number } { + let calls = 0 + return { + store, + probeRecord: async () => { + calls += 1 + return probe(calls) + }, + now: () => NOW + 10_000, + delay: async () => {}, + probes: () => calls, + ...overrides + } +} + +describe('structured session recovery resolution', () => { + it('does not release an ownerless native reservation without processless proof', async () => { + const store = await openStore() + await reserve(store) + await latch(store, 'recovering') + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'indeterminate', reason: 'no scan' })), + SESSION + ) + + expect(result).toBe('unresolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + runtimeFence: 1, + reservedSpawnToken: 'spawn-recovery' + }) + }) + + it('evicts a latched owner the probe now proves dead, without a stop request', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'manual-recovery') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' }), { stopOwnerProcess }), + SESSION + ) + + expect(result).toBe('resolved') + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null + }) + }) + + it('stops a live identity-matched orphan and evicts only after absence is proven', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + let alive = true + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + + const result = await resolveStructuredSessionRecovery( + deps( + store, + () => + alive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + { stopOwnerProcess } + ), + SESSION + ) + + expect(result).toBe('resolved') + expect(stopOwnerProcess).toHaveBeenCalledTimes(1) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + }) + + it('escalates the stop request but never evicts an owner that stays alive', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), { + stopOwnerProcess + }), + SESSION + ) + + expect(result).toBe('unresolved') + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGKILL') + // The latch is preserved verbatim: no fence move, no cleared owner, no lost state. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: 'recovering', + runtimeFence: 1, + ownerProcess: { pid: 4242 } + }) + }) + + it('leaves an unverifiable owner latched and requests no stop', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'indeterminate', reason: 'probe timed out' }), { + stopOwnerProcess + }), + SESSION + ) + + expect(result).toBe('unresolved') + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: 'recovering', + runtimeFence: 1 + }) + }) + + it('leaves a TUI record that still names an owner to its own recovery transport', async () => { + const store = await openStore() + await liveOwner(store, 'tui') + await latch(store, 'recovering') + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' })), + SESSION + ) + ).toBe('not-applicable') + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + }) + + it('resolves a TUI reservation that names nobody, because nothing else can', async () => { + // The TUI carve-out exists because a TUI owner has its own recovery transport, and that + // transport needs a process to talk to. A reservation that crashed before `commitProcessIdentity` + // names none, so skipping it here left the session with no exit at all. + const store = await openStore() + await reserve(store, 'tui') + await latch(store, 'recovering') + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'reservation-unused' })), + SESSION + ) + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released' + }) + }) + + it('frees a conflicted claim once its named owner is proven gone', async () => { + const store = await openStore() + await liveOwner(store) + await store.markClaimConflicted(SESSION, NOW) + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' })), + SESSION + ) + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released', + deathEvidence: { kind: 'pid-absent' } + }) + }) + + it('never stops the process a conflicted claim names, and keeps the conflict without proof', async () => { + const store = await openStore() + await liveOwner(store) + await store.markClaimConflicted(SESSION, NOW) + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { + stopOwnerProcess + }), + SESSION + ) + + // Ownership was never settled, so the process on the other side of the conflict is not + // Orca's to kill; only the user can decide which claimant wins. + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(result).toBe('unresolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts new file mode 100644 index 00000000000..c570db24333 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -0,0 +1,124 @@ +/** + * Exits from latched recovery stages. A session lands in `recovering` / `manual-recovery` + * when evidence about its owner was UNAVAILABLE; this module re-asks with present-time + * evidence and releases the lease only on proof. A stop is a request — the lease moves only + * after a later probe proves the process absent, never on a timeout. + */ + +import { + isProvenAliveProbe, + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' + +export type StructuredSessionRecoveryResolutionDeps = { + store: AgentSessionRecordStore + probeRecord: (record: AgentSessionRecord) => Promise + now: () => number + stopOwnerProcess?: (pid: number, signal: StructuredSessionRecoveryStopSignal) => void + delay?: (ms: number) => Promise +} + +const STOP_PROBES_PER_SIGNAL = 4 +const STOP_PROBE_INTERVAL_MS = 250 + +const UNRESOLVED_REFUSALS: ReadonlySet = new Set([ + 'agent_session_ownership_unknown', + 'agent_session_checkpoint_stale', + 'execution_owner_reconciling', + 'agent_session_identity_required' +]) + +/** Which latched records this module may re-ask about. */ +export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord): boolean { + const { claimStatus, handoffStage, ownerProcess, runtimeKind } = record.lease + if (handoffStage !== 'recovering' && handoffStage !== 'manual-recovery') { + return false + } + if (claimStatus === 'conflicted') { + // A conflict names one process. Re-asking is only meaningful against that name; with none + // recorded there is nothing present-time evidence could settle, and the user decides. + return ownerProcess !== null + } + // A TUI owner has its own recovery transport — but that transport needs a process to talk to + // (`structuredManualRecoveryIsAdmissible` requires one). A TUI reservation that crashed before + // its identity was committed names nobody, so nothing else in the system can exit it. + return runtimeKind === 'native' || ownerProcess === null +} + +/** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim + * means ownership was never settled, and a TUI child is the user's foreground agent. */ +function recoveryMayStopOwner(record: AgentSessionRecord): boolean { + return record.lease.runtimeKind === 'native' && record.lease.claimStatus !== 'conflicted' +} + +export async function resolveStructuredSessionRecovery( + deps: StructuredSessionRecoveryResolutionDeps, + sessionId: string +): Promise<'resolved' | 'unresolved' | 'not-applicable'> { + const record = deps.store.getRecord(sessionId) + if (!record || !structuredSessionRecoveryIsResolvable(record)) { + return 'not-applicable' + } + let probe = await deps.probeRecord(record) + const owner = record.lease.ownerProcess + if ( + owner && + owner.hostId === deps.store.hostId && + isProvenAliveProbe(probe) && + recoveryMayStopOwner(record) + ) { + // The owner is a live child of a runtime that no longer exists; its transport cannot be + // reconstructed, so the only way forward is to stop it and prove it gone. + probe = await stopOwnerAndReprobe(deps, record, owner.pid) + } + try { + await deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + }) + return 'resolved' + } catch (error) { + const code = error instanceof Error ? error.message : String(error) + if (UNRESOLVED_REFUSALS.has(code)) { + // No proof yet; the record is preserved untouched and the next attempt re-asks. + return 'unresolved' + } + throw error + } +} + +async function stopOwnerAndReprobe( + deps: StructuredSessionRecoveryResolutionDeps, + record: AgentSessionRecord, + pid: number +): Promise { + const stop = deps.stopOwnerProcess ?? defaultStopOwnerProcess + const delay = deps.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'owner stop requested' } + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + stop(pid, signal) + for (let attempt = 0; attempt < STOP_PROBES_PER_SIGNAL; attempt += 1) { + probe = await deps.probeRecord(record) + if (isProvenDeadProbe(probe)) { + return probe + } + await delay(STOP_PROBE_INTERVAL_MS) + } + } + return probe +} + +function defaultStopOwnerProcess(pid: number, signal: StructuredSessionRecoveryStopSignal): void { + try { + process.kill(pid, signal) + } catch { + // Already gone or not ours to signal; the next probe supplies the actual proof. + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts new file mode 100644 index 00000000000..f9e67c0efad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -0,0 +1,45 @@ +/** + * Human-readable text for a lease refusal. + * + * A latched session is the one place a bare code is worst: the user is looking at a chat that will + * not open, and `agent_session_ownership_unknown` tells them neither what Orca could not prove nor + * what they can do about it. Every message here names the specific evidence that is missing and + * the action that supplies it. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' + +function ownerDescription(record: AgentSessionRecord): string { + const owner = record.lease.ownerProcess + return owner ? `process ${owner.pid} on ${owner.hostId}` : 'a process it never got to record' +} + +function latchedMessage(record: AgentSessionRecord): string { + const owner = record.lease.ownerProcess + if (record.lease.claimStatus === 'conflicted') { + return owner + ? `Two runtimes claimed this session and Orca cannot yet prove that ${ownerDescription(record)} has exited. Quit that process, or reopen this chat once it is gone, and Orca will take the session back.` + : 'Two runtimes claimed this session and the record names no process to check. Quit any other Orca or agent process using this workspace, then reopen this chat.' + } + return owner + ? `Orca cannot prove that ${ownerDescription(record)} — the previous owner of this session — has exited, so it will not start a second agent on the same conversation. Quit that process and reopen this chat.` + : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' +} + +/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +export function structuredAgentSessionRefusalMessage( + code: AgentSessionWireRefusalCode, + record: AgentSessionRecord | null +): string | null { + if (!record) { + return null + } + if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { + return latchedMessage(record) + } + if (code === 'execution_owner_reconciling') { + return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + } + return null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..758ed520ee4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -0,0 +1,372 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import { AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT } from '../../../shared/agent-session-operation-ledger' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionRefusalOperationState, + type AgentSessionRefusalOperationState +} from '../../../shared/agent-session-refusal-retry' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const METHODS = ['agentSession.setOption', 'agentSession.send'] as const +type Method = (typeof METHODS)[number] +type Pair = `${Method}:${AgentSessionWireRefusalCode}` + +type CallSpec = { + method: Method + operationId: string + expectedRuntimeFence?: number + payloadFingerprint?: string +} + +type Harness = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + setOption: Mock +} + +const harnesses: Harness[] = [] +let operationSequence = 1_000 + +function operationId(timestamp = NOW): string { + operationSequence += 1 + return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + const unused = async (): Promise => { + throw new Error('unused handoff transport') + } + return { + hostLabel: 'test-host', + launchTui: unused, + reproveTuiOwner: unused, + recoverTuiOwner: unused, + stopRecoveredOwner: async () => undefined, + waitForTuiExit: unused, + waitForTuiIdleOrExit: unused, + tuiStatus: () => 'idle' + } +} + +async function createHarness(options: { attached?: boolean; transport?: boolean } = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const setOption = vi.fn(async () => undefined) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption + } + const host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...(options.transport ? { handoffTransport: handoffTransport() } : {}) + }) + const harness = { root, store, host, setOption } + harnesses.push(harness) + if (options.attached !== false) { + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + } + return harness +} + +afterEach(async () => { + const completed = harnesses.splice(0) + await Promise.all(completed.map(async ({ host }) => host.flushAllStreamedEvents())) + await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) +}) + +function callFields(spec: CallSpec): Record { + if (spec.method === 'agentSession.send') { + return { body: hostTestMessage('host oracle') } + } + return { key: 'model', value: 'gpt-5' } +} + +function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { + const fields = callFields(spec) + return { + sessionId: SESSION, + clientOperationId: spec.operationId, + expectedRuntimeFence: + spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: + spec.payloadFingerprint ?? + computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) + } +} + +function invoke(harness: Harness, spec: CallSpec): Promise> { + const fields = callFields(spec) + const mutationEnvelope = envelope(harness, spec) + if (spec.method === 'agentSession.send') { + return harness.host.send(CALLER, { + envelope: mutationEnvelope, + body: fields.body as ReturnType + }) + } + return harness.host.setOption(CALLER, { + envelope: mutationEnvelope, + key: fields.key as string, + value: fields.value as string + }) +} + +function operationState(harness: Harness, operation: string) { + return harness.store + .listOperationRows() + .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome +} + +async function assertHostAgreement( + harness: Harness, + spec: CallSpec, + code: AgentSessionWireRefusalCode, + retryOnFreshHost = false +): Promise { + try { + const result = await invoke(harness, spec) + expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) + } catch (error) { + expect(error).toMatchObject({ message: code }) + } + const outcome = operationState(harness, spec.operationId) + let oracle: AgentSessionRefusalOperationState + if (outcome?.status === 'failed') { + oracle = 'settled-rejected' + } else if (outcome?.status === 'unknown') { + oracle = 'unknown' + } else if (outcome?.status === 'pending') { + oracle = 'pending-admission' + } else { + const retryHarness = retryOnFreshHost ? await createHarness() : harness + await invoke(retryHarness, spec) + oracle = operationState(retryHarness, spec.operationId) + ? 'pending-admission' + : 'settled-rejected' + } + expect(agentSessionRefusalOperationState(spec.method, code), `${spec.method}:${code}`).toBe( + oracle + ) + return `${spec.method}:${code}` +} + +async function setLease( + harness: Harness, + update: (record: AgentSessionRecord) => AgentSessionRecord +): Promise { + await harness.store.transitionHandoff(SESSION, update) +} + +async function fillOperationLedger(harness: Harness): Promise { + while ( + harness.store.listOperationRows().filter((row) => row.callerKey === CALLER.callerKey).length < + AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT + ) { + await harness.store.admitOperation({ + callerKey: CALLER.callerKey, + operationId: operationId(), + fingerprint: 'capacity-fixture', + now: NOW + }) + } +} + +// sendPlan and setOptionPlan have no unsupported branch; only handoff checked transport. +const UNREACHABLE = new Set([ + 'agentSession.send:structured_agent_session_unsupported', + 'agentSession.setOption:structured_agent_session_unsupported', + // performPrompt is the sole producer of prompt revision and resolution refusals. + 'agentSession.setOption:agent_session_item_revision_stale', + 'agentSession.send:agent_session_item_revision_stale', + 'agentSession.setOption:agent_session_already_resolved', + 'agentSession.send:agent_session_already_resolved', + // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. + 'agentSession.setOption:agent_session_identity_required', + 'agentSession.send:agent_session_identity_required', + // No structured-agent-session host branch emits agent_session_journal_unreadable. + 'agentSession.setOption:agent_session_journal_unreadable', + 'agentSession.send:agent_session_journal_unreadable' +]) + +describe('agentSessionRefusalOperationState host oracle', () => { + // 26 real host round trips, each committing the store — and every commit now also rotates a + // durable backup, so this does substantially more fsync work than the budget was set for. + it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { + const produced = new Set() + const record = (pair: Pair) => produced.add(pair) + + const stale = await createHarness() + for (const method of METHODS) { + record( + await assertHostAgreement( + stale, + { + method, + operationId: operationId(), + expectedRuntimeFence: 99 + }, + 'agent_session_checkpoint_stale' + ) + ) + } + + const conflict = await createHarness({ transport: true }) + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, runtimeKind: 'tui' } + })) + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + conflict, + { method, operationId: operationId() }, + 'agent_session_conflict' + ) + ) + } + + const absent = await createHarness({ attached: false }) + for (const method of METHODS) { + record( + await assertHostAgreement( + absent, + { method, operationId: operationId() }, + 'agent_session_ownership_unknown', + true + ) + ) + } + + const operationConflict = await createHarness() + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + operationConflict, + { + method, + operationId: operationId(), + payloadFingerprint: 'wrong' + }, + 'agent_session_operation_conflict' + ) + ) + } + const ledgerRefusals = await createHarness() + for (const [code, timestamp] of [ + ['agent_session_operation_expired', NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1], + ['agent_session_operation_invalid', null] + ] as const) { + for (const method of METHODS) { + record( + await assertHostAgreement( + ledgerRefusals, + { + method, + operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) + }, + code + ) + ) + } + } + + const capacity = await createHarness() + await fillOperationLedger(capacity) + for (const method of METHODS) { + record( + await assertHostAgreement( + capacity, + { method, operationId: operationId() }, + 'agent_session_operation_capacity', + true + ) + ) + } + + const unknown = await createHarness() + unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) + const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } + await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') + record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) + + const appendFailure = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockRejectedValueOnce(new Error('journal write failed')) + const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } + await expect(invoke(unknown, sendUnknown)).rejects.toThrow('journal write failed') + appendFailure.mockRestore() + record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) + + const reconciling = await createHarness() + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: true } + })) + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + reconciling, + { method, operationId: operationId() }, + 'execution_owner_reconciling' + ) + ) + } + + const allPairs = METHODS.flatMap((method) => + AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) + ) + expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) + expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts new file mode 100644 index 00000000000..b7a26bb8e3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -0,0 +1,18 @@ +// Where the RPC layer finds the host. +// +// The runtime service is already far past its size budget, so structured +// sessions hang off a module-level slot instead of another field on it — the +// same shape the native-chat RPC methods use to reach their own collaborators. +// Tests install a host with a stub adapter and clear it on teardown. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +let host: StructuredAgentSessionHost | null = null + +export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { + host = next +} + +export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts new file mode 100644 index 00000000000..452e6a6fae0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts @@ -0,0 +1,75 @@ +// The delay between "nothing holds this session" and "stop its provider child". +// +// TWO reasons it is not immediate. A surface that reconnects — a mobile socket dropping on a +// network switch, a renderer remounting a tab — releases and re-holds within a second, and killing +// an app-server in that window costs the user a respawn plus a resume for nothing. And a turn the +// user already asked for must finish: the provider is mid-answer, the journal has an open turn +// marker, and stopping the child there strands both. +// +// So the clock arms when the last holder leaves, and a tick that finds a turn still running RE-ARMS +// instead of evicting. That is what makes the wait start at the later of the two events rather than +// at whichever came first. + +export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 15_000 + +export type StructuredAgentSessionReleaseClockDeps = { + /** Never evict mid-turn; a true answer re-arms the clock instead. */ + isTurnActive: (sessionId: string) => boolean + /** Re-checked at fire time: a holder may have arrived while the timer ran. */ + isHeld: (sessionId: string) => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + graceMs?: number +} + +export class StructuredAgentSessionReleaseClock { + private readonly timers = new Map>() + private readonly graceMs: number + + constructor(private readonly deps: StructuredAgentSessionReleaseClockDeps) { + this.graceMs = deps.graceMs ?? STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS + } + + arm(sessionId: string): void { + this.cancel(sessionId) + const timer = setTimeout(() => { + this.timers.delete(sessionId) + this.fire(sessionId) + }, this.graceMs) + // A pending release must never be the reason a process stays alive at quit. + timer.unref?.() + this.timers.set(sessionId, timer) + } + + cancel(sessionId: string): void { + const timer = this.timers.get(sessionId) + if (timer) { + clearTimeout(timer) + this.timers.delete(sessionId) + } + } + + isArmed(sessionId: string): boolean { + return this.timers.has(sessionId) + } + + dispose(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + } + + private fire(sessionId: string): void { + if (this.deps.isHeld(sessionId)) { + return + } + if (this.deps.isTurnActive(sessionId)) { + this.arm(sessionId) + return + } + void this.deps.evict(sessionId).catch((error: unknown) => { + this.deps.onError?.({ sessionId, error }) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts new file mode 100644 index 00000000000..a15cd9bf8be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -0,0 +1,60 @@ +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' + +export type AgentSessionReplayOutcomeDecision = + | { decision: 'replay'; value: TValue } + | { decision: 'rerun' } + | { decision: 'refuse'; refusal: AgentSessionWireRefusal } + +export function resolveAgentSessionReplayOutcome(input: { + operationId: string + outcome: AgentSessionOperationOutcome + reconstruct: () => TValue | null + rerunWhenReplayMissing?: boolean +}): AgentSessionReplayOutcomeDecision { + const { operationId, outcome } = input + if (outcome.status === 'failed') { + const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) + ? (outcome.code as AgentSessionWireRefusalCode) + : 'agent_session_operation_invalid' + return { + decision: 'refuse', + refusal: { + code, + message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + } + } + } + if (outcome.status === 'unknown') { + if (input.rerunWhenReplayMissing) { + return { decision: 'rerun' } + } + return { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `The outcome of operation ${operationId} is unknown; it was not run again.` + } + } + } + const recorded = input.reconstruct() + if (recorded) { + return { decision: 'replay', value: recorded } + } + if (input.rerunWhenReplayMissing) { + return { decision: 'rerun' } + } + return outcome.status === 'succeeded' + ? { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + } + } + : { decision: 'rerun' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts new file mode 100644 index 00000000000..e61587a6598 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' + +describe('createRestartReconciler', () => { + it('reruns after an external store refresh introduces unreconciled leases', async () => { + let record = { sessionId: 'session-1', lease: { unreconciled: true } } as AgentSessionRecord + const reconcileOnRestart = vi.fn(async () => { + record = { ...record, lease: { ...record.lease, unreconciled: false } } + return new Map() + }) + const store = { + listRecords: () => [record], + getRecord: () => record, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + const reconcile = createRestartReconciler({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => 1 + }) + + expect(await reconcile('session-1')).toBeNull() + record = { ...record, lease: { ...record.lease, unreconciled: true } } + expect(await reconcile('session-1')).toBeNull() + expect(reconcileOnRestart).toHaveBeenCalledTimes(2) + }) + + it('passes every pending record through the batch owner probe', async () => { + let records = [ + { sessionId: 'session-1', lease: { unreconciled: true } }, + { sessionId: 'session-2', lease: { unreconciled: true } } + ] as AgentSessionRecord[] + const probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + const probeMany = vi.fn(async (pending: readonly AgentSessionRecord[]) => { + return new Map( + pending.map((record) => [record.sessionId, { outcome: 'pid-absent' as const }]) + ) + }) + const reconcileOnRestart = vi.fn( + async (args: { + probeMany?: ( + pending: readonly AgentSessionRecord[] + ) => Promise> + }) => { + await args.probeMany?.(records) + records = records.map((record) => ({ + ...record, + lease: { ...record.lease, unreconciled: false } + })) + return new Map() + } + ) + const store = { + listRecords: () => records, + getRecord: (sessionId: string) => + records.find((record) => record.sessionId === sessionId) ?? null, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + + await expect( + createRestartReconciler({ store, probe, probeMany, now: () => 1 })('session-1') + ).resolves.toBeNull() + + expect(probeMany).toHaveBeenCalledOnce() + expect(probeMany.mock.calls[0]?.[0].map((record) => record.sessionId)).toEqual([ + 'session-1', + 'session-2' + ]) + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts new file mode 100644 index 00000000000..3b6cc30271e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -0,0 +1,68 @@ +// Every lease loads from disk unreconciled: the process that wrote it may still +// be alive, so nothing the store persisted grants a writer until this host has +// adjudicated it. Without this an attach after a restart is refused forever with +// `execution_owner_reconciling`, and the session becomes unreachable. + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { classifyStoreFailure } from './structured-agent-session-attach' + +const MAX_RECONCILIATION_PASSES = 8 + +/** Adjudicates leases loaded by this process or refreshed from another writer. + * Answers with the refusal attach owes its caller, or null once settled. */ +export function createRestartReconciler(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number +}): (sessionId: string) => Promise { + let pending: Promise | null = null + return async (sessionId) => { + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return null + } + if (!pending) { + const run = reconcileCurrentLeases(deps) + pending = run.finally(() => { + pending = null + }) + } + try { + await pending + return null + } catch (error) { + return classifyStoreFailure( + error, + deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null, + deps.store.getRecord(sessionId) + ) + } + } +} + +async function reconcileCurrentLeases(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number +}): Promise { + for (let pass = 0; pass < MAX_RECONCILIATION_PASSES; pass += 1) { + await deps.store.reconcileOnRestart({ + probe: deps.probe, + ...(deps.probeMany ? { probeMany: deps.probeMany } : {}), + now: deps.now() + }) + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return + } + } + // An outgoing runtime can still be writing during restart; preserve the record and retry later. + throw new Error('execution_owner_reconciling') +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts new file mode 100644 index 00000000000..4567d84d5c0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' + +describe('StructuredAgentSessionRestartRestoreGate', () => { + it('runs one successful restore across concurrent and later discovery', async () => { + const gate = new StructuredAgentSessionRestartRestoreGate() + const restore = vi.fn(async () => undefined) + + await Promise.all([gate.run(restore), gate.run(restore), gate.run(restore)]) + await gate.run(restore) + + expect(restore).toHaveBeenCalledOnce() + }) + + it('allows a failed restore to be retried', async () => { + const gate = new StructuredAgentSessionRestartRestoreGate() + const restore = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValue(undefined) + + await expect(gate.run(restore)).rejects.toThrow('restore failed') + await expect(gate.run(restore)).resolves.toBeUndefined() + + expect(restore).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts new file mode 100644 index 00000000000..dd4237dcb28 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts @@ -0,0 +1,17 @@ +export class StructuredAgentSessionRestartRestoreGate { + private current: Promise | null = null + + run(restore: () => Promise): Promise { + if (this.current) { + return this.current + } + const tracked = restore().catch((error: unknown) => { + if (this.current === tracked) { + this.current = null + } + throw error + }) + this.current = tracked + return tracked + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts new file mode 100644 index 00000000000..d0dcd38b986 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const { restoreRead } = vi.hoisted(() => ({ + restoreRead: vi.fn() +})) + +vi.mock('./structured-agent-session-read-restore', () => ({ + restoreStructuredAgentSessionRead: restoreRead +})) + +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +describe('restart journal restoration', () => { + beforeEach(() => restoreRead.mockReset()) + + it('bounds historical journal parsing to four sessions at a time', async () => { + const gate = Promise.withResolvers() + let active = 0 + let peak = 0 + restoreRead.mockImplementation(async (_store, _root, sessionId: string) => { + active += 1 + peak = Math.max(peak, active) + await gate.promise + active -= 1 + return { + journal: {}, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, + fence: 1, + hasProviderChild: false, + sessionId + } + }) + const records = Array.from( + { length: 12 }, + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + const restoration = restoreStructuredAgentSessionsOnRestart({ + store: {} as never, + journalRoot: '/tmp/journals', + records, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined, + restoreHandoff: async () => undefined + }) + + await vi.waitFor(() => expect(active).toBe(4)) + expect(restoreRead).toHaveBeenCalledTimes(4) + gate.resolve() + await restoration + + expect(restoreRead).toHaveBeenCalledTimes(records.length) + expect(peak).toBe(4) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts new file mode 100644 index 00000000000..6eb6fe15059 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -0,0 +1,60 @@ +// What a restart owes a persisted session, and what it does NOT. +// +// It owes reconciliation — every lease loaded from disk names an owner from a process generation +// that no longer exists, and adjudicating that is startup's job. It owes an exit from any recovery +// stage the evidence now permits. And it owes a READABLE session: the journal open, history +// answerable, the tab restorable. +// +// It does not owe a provider child. This used to resume every record whose lease was `released` +// with no handoff in flight, which is the normal end state of a chat the user closed cleanly — so a +// healthy profile started an app-server per session it had ever used, in parallel, at every launch, +// with no client attached and nothing on screen. A child now exists because a surface asked for the +// session (see `structured-agent-session-holds`), not because a record survived on disk. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import { + restoreStructuredAgentSessionRead, + type RestoredStructuredAgentSessionRead +} from './structured-agent-session-read-restore' + +const JOURNAL_RESTORE_CONCURRENCY = 4 + +export async function restoreStructuredAgentSessionsOnRestart(input: { + store: AgentSessionRecordStore + journalRoot: string + records: AgentSessionRecord[] + reconcile: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + hasSession: (sessionId: string) => boolean + onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise +}): Promise { + await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { + const unreconciled = await input.reconcile(sessionId) + if (!unreconciled) { + // A session latched in recovery exits here at startup, without waiting for a client. + await input.resolveRecovery(sessionId) + } + await input.serialize(sessionId, async () => { + if (input.hasSession(sessionId)) { + // A surface that took a hold mid-restore already attached this one. + await input.restoreHandoff(sessionId) + return + } + const restored = await restoreStructuredAgentSessionRead( + input.store, + input.journalRoot, + sessionId + ) + if (!restored) { + return + } + input.onReadable(sessionId, restored) + await input.restoreHandoff(sessionId) + }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts new file mode 100644 index 00000000000..0baf6fe9952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -0,0 +1,41 @@ +// When a record may be handed back a provider child. +// +// This used to be inline in the restart restore, which is what made it a STARTUP rule: every record +// whose lease looked like this got a child, at launch, whether or not anything was going to look at +// it. `released` + no handoff is the normal end state of a chat the user closed cleanly, so a +// healthy profile respawned everything it had ever opened. The predicate itself was never wrong — +// it answers "may this be resumed", not "should it be" — so it lives here now and the caller that +// knows a surface is asking is the only one that acts on it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { randomUUID } from 'node:crypto' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-read-restore' + +export function isResumableStructuredAgentSessionRecord(record: AgentSessionRecord): boolean { + return ( + !record.lease.unreconciled && + record.lease.claimStatus === 'released' && + record.lease.handoffStage === null + ) +} + +/** Attach params for a resume, or null when this record's lease is somebody else's problem. */ +export function structuredAgentSessionResumeParams( + record: AgentSessionRecord, + clientOperationId: string +): AgentSessionAttachParams | null { + if (!isResumableStructuredAgentSessionRecord(record)) { + return null + } + return attachParamsForRecord(record, { + clientOperationId, + expectedRuntimeFence: record.lease.runtimeFence, + runtimeKind: 'native' + }) +} + +/** `<13-digit ms>-<32 hex>`, the only operation-id shape the durable ledger admits. */ +export function structuredAgentSessionResumeOperationId(now: number): string { + return `${Math.trunc(now).toString().padStart(13, '0')}-${randomUUID().replaceAll('-', '')}` +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts new file mode 100644 index 00000000000..8b6a73fd57b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' + +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-send-idempotency-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured send idempotency', () => { + it('does not redispatch one send id reused across caller ledgers', async () => { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'one durable send' }] + } + const dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 + } + })) + const context: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'caller', + publish: vi.fn(), + now: () => 1 + } + const input = { + clientMessageId: 'shared-send-id', + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body } + }), + body + } + + await performSend(context, input) + const replay = await performSend(context, input) + + expect(replay).toMatchObject({ + ok: true, + value: { clientMessageId: 'shared-send-id', submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(journal.submissions()).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts new file mode 100644 index 00000000000..24dc81f4684 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -0,0 +1,392 @@ +// What a client's retry sees around a failed attach settlement: a crash between +// reserve and settlement replays into the original reservation, and a settled +// failure refuses sends without ever re-dispatching on the user's behalf. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type * as DurableFileWrite from '../../durable-file-write' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) + +vi.mock('../../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + renameDurable: async (tmpPath: string, finalPath: string) => { + if (finalPath.endsWith('agent-sessions.json')) { + publishFault.publishCount += 1 + } + if ( + finalPath.endsWith('agent-sessions.json') && + publishFault.publishCount === publishFault.failOnPublish + ) { + throw new Error('simulated crash before failed-settlement publish') + } + return actual.renameDurable(tmpPath, finalPath) + } + } +}) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let releaseAcquisition: Mock> +let dispatch: Mock + +function accepted(): AgentSessionDispatchOutcome { + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition, + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } +} + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) + publishFault.failOnPublish = 0 + publishFault.publishCount = 0 + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + releaseAcquisition = vi.fn(async () => true) + dispatch = vi.fn(async () => accepted()) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + publishFault.failOnPublish = 0 + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('settled attach retry', () => { + it('settles a post-acquisition journal failure and retries without a restart', async () => { + const historyFilePath = vi + .fn>() + .mockRejectedValueOnce(new Error('journal path unavailable')) + .mockResolvedValue(null) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), historyFilePath }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const first = hostTestAttachParams(null) + + await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') + expect(releaseAcquisition).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + runtimeFence: 2 + }) + expect( + store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) + ?.outcome + ).toMatchObject({ status: 'failed' }) + + await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3 + }) + }) + + it('continues a safe same-host pending replay with the reserved token', async () => { + const spawnTokens: string[] = [] + acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + if (spawnTokens.length === 1) { + throw new Error('reply lost') + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + const mintSpawnToken = vi.fn(() => 'spawn-safe') + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + now: () => NOW + }) + const params = hostTestAttachParams(null) + publishFault.failOnPublish = 2 + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent session acquisition failure settlement failed' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(mintSpawnToken).toHaveBeenCalledOnce() + expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) + }) + + it('fences a crash-interrupted reservation replay until positive recovery', async () => { + const spawnTokens: string[] = [] + acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + if (spawnTokens.length === 1) { + throw new Error('reply lost') + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + let token = 0 + const mintSpawnToken = vi.fn(() => `spawn-${++token}`) + let reservationUnused = false + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + probeOwner: async () => + reservationUnused + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + }) + const params = hostTestAttachParams(null) + publishFault.failOnPublish = 2 + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent session acquisition failure settlement failed' + ) + expect(publishFault.publishCount).toBe(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeFence: 1, + reservedSpawnToken: 'spawn-1', + ownerProcess: null + }) + + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + probeOwner: async () => + reservationUnused + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + }) + + const refused = await host.attach(CALLER, params) + if (refused.ok) { + throw new Error('expected the replayed reservation to stay fenced') + } + expect(refused.refusal.code).toBe('agent_session_ownership_unknown') + expect(acquire).toHaveBeenCalledTimes(1) + expect(releaseAcquisition).toHaveBeenCalledTimes(1) + expect(mintSpawnToken).toHaveBeenCalledTimes(1) + expect(spawnTokens).toEqual(['spawn-1']) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery', + runtimeFence: 1, + reservedSpawnToken: 'spawn-1', + ownerProcess: null + }) + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.outcome + ).toEqual({ status: 'pending' }) + + reservationUnused = true + await host.hold(SESSION, 'desktop-chat:retry') + expect(mintSpawnToken).toHaveBeenCalledTimes(2) + expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeKind: 'native', + runtimeFence: 3, + handoffStage: null, + handoffOperationId: null, + ownerProcess: { spawnToken: 'spawn-2' } + }) + }) + + it('restores an unknown submission without redispatch before a distinct send', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('possibly delivered') + const unknownParams = { + envelope: envelope('agentSession.send', { body }), + body + } + const first = await host.send(CALLER, unknownParams) + expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-restarted', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + await host.restoreReadableSessions() + await host.hold(SESSION, 'desktop-chat:restart') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3 + }) + expect(dispatch).toHaveBeenCalledTimes(1) + + const newBody = hostTestMessage('are you there?') + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: newBody }), + body: newBody + }) + if (!sent.ok) { + throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) + } + expect(dispatch).toHaveBeenCalledTimes(2) + const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + if (!restoredHistory.ok) { + throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) + } + expect( + restoredHistory.page.submissions.find( + (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId + )?.dispatchState + ).toBe('unknown') + + const explicitRetry = await host.send(CALLER, { + ...unknownParams, + envelope: { + ...unknownParams.envelope, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 + }, + retryUnknown: true + }) + expect(explicitRetry).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(3) + }) + + it('records proven acquisition cleanup as durable death evidence', async () => { + acquire.mockRejectedValueOnce(new Error('resume rejected')) + + await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow('resume rejected') + + expect(releaseAcquisition).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains' + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts new file mode 100644 index 00000000000..18d2853a9f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -0,0 +1,195 @@ +import { appendFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionHandoffStatus, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { serializeJournalRow, type JournalRow } from '../agent-session-journal/journal-row-schema' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const SESSION = 'subscriber-session' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-subscribers-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('AgentSessionSubscribers', () => { + it('publishes the current fence when a resumed cursor is already caught up', async () => { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'checkpoint-journal') + }) + const events: AgentSessionSubscribeEvent[] = [] + + new AgentSessionSubscribers().open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 7, + cursor: journal.cursor(), + emit: (event) => events.push(event) + }) + + expect(events).toEqual([ + { + type: 'batch', + sessionId: SESSION, + batch: { + cursor: journal.cursor(), + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 7 + } + ]) + }) + + it('publishes handoff-only changes without serializing a transcript snapshot', async () => { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'journal') + }) + const subscribers = new AgentSessionSubscribers() + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 1, + emit: (event) => events.push(event) + }) + const handoff: AgentSessionHandoffStatus = { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + } + + subscribers.handoff(SESSION, 2, handoff) + + expect(events.at(-1)).toEqual({ + type: 'batch', + sessionId: SESSION, + batch: { + cursor: journal.cursor(), + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 2, + handoff + }) + }) + + it('catches a subscriber up past a pre-existing unsendable removal with a bounded reset', async () => { + const journalDir = join(root, 'oversized-removal-journal') + const seeded = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + // A row admitted before identity bounding: its removal id alone exceeds + // the outbound cap, so no catch-up batch can ever carry it. + const hugeItemId = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + const resumeCursor = seeded.cursor() + const seq = resumeCursor.sequence + const rows: JournalRow[] = [ + { + kind: 'item', + itemId: hugeItemId, + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'big' }] }, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 1, + fence: 1, + ts: 2_000 + }, + { + kind: 'tombstone', + itemId: hugeItemId, + revision: 2, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 2, + fence: 1, + ts: 2_001 + } + ] + await appendFile( + join(journalDir, JOURNAL_LOG_FILE), + `${rows.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + + const subscribers = new AgentSessionSubscribers() + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 1, + cursor: resumeCursor, + emit: (event) => events.push(event) + }) + + // Every event a remote subscriber receives must fit the outbound channel. + for (const event of events) { + expect(Buffer.byteLength(serializeRemoteRuntimePayload(event), 'utf8')).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + } + const reset = events.find((event) => event.type === 'reset') + expect(reset).toBeDefined() + + // Forward progress: the reset advanced the subscriber past the unsendable + // row, so the next publish has nothing stale to re-deliver. + const settled = events.length + subscribers.publish(SESSION, journal) + expect(events.slice(settled).filter((event) => event.type === 'reset')).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts new file mode 100644 index 00000000000..3fa80b28d85 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -0,0 +1,233 @@ +// Per-subscriber cursors over one session's journal. +// +// Each subscriber advances independently: a client that connected two epochs +// ago gets a reset while a caught-up one gets a batch from the same publish. +// Nothing raw reaches a subscriber — every event carries reducer output. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHandoffStatus, + type AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from './agent-session-history-page' + +export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void +export type AgentSessionSubscribeInput = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor +} + +type Subscriber = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor: AgentJournalCursor + fence: number +} + +export class AgentSessionSubscribers { + private readonly bySession = new Map>() + + /** Opens the stream with a bounded tail page or, when the client's cursor + * still resolves, with the rows it missed. Returns the disposer. */ + open(input: { + id: string + sessionId: string + journal: AgentSessionJournal + fence: number + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + handoff?: AgentSessionHandoffStatus + }): () => void { + const liveCursor = input.journal.cursor() + const subscriber: Subscriber = { + id: input.id, + sessionId: input.sessionId, + emit: input.emit, + cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 }, + fence: input.fence + } + const session = this.bySession.get(input.sessionId) ?? new Map() + session.set(input.id, subscriber) + this.bySession.set(input.sessionId, session) + + if (input.cursor) { + this.deliver(subscriber, input.journal, input.handoff, true) + } else { + const page = readAgentSessionHydrationPage(input.journal, input.fence) + this.emit(subscriber, { + type: 'snapshot', + sessionId: input.sessionId, + page, + fence: input.fence, + ...(input.handoff ? { handoff: input.handoff } : {}) + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + } + return () => this.close(input.sessionId, input.id) + } + + close(sessionId: string, id: string): void { + const session = this.bySession.get(sessionId) + const subscriber = session?.get(id) + if (!session || !subscriber) { + return + } + this.drop(subscriber) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. + } + } + + /** Fan out whatever each subscriber has not yet seen. */ + publish(sessionId: string, journal: AgentSessionJournal): void { + for (const subscriber of this.subscribers(sessionId)) { + this.deliver(subscriber, journal) + } + } + + /** Force every subscriber back to a bounded tail page — recovery, epoch + * rollover, an unreadable schema. */ + reset( + sessionId: string, + journal: AgentSessionJournal, + reason: AgentJournalResetReason, + fence: number + ): void { + const page = readAgentSessionHydrationPage(journal, fence) + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'reset', sessionId, reset: reason, page, fence }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.fence = fence + } + } + + snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void { + const page = readAgentSessionHydrationPage(journal, fence) + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, page, fence }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.fence = fence + } + } + + handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { + type: 'batch', + sessionId, + batch: { + cursor: subscriber.cursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence, + handoff + }) + subscriber.fence = fence + } + } + + private subscribers(sessionId: string): Subscriber[] { + return [...(this.bySession.get(sessionId)?.values() ?? [])] + } + + private deliver( + subscriber: Subscriber, + journal: AgentSessionJournal, + handoff?: AgentSessionHandoffStatus, + emitCheckpoint = false + ): void { + while (true) { + const result = readAgentSessionHistory(journal, { + sessionId: subscriber.sessionId, + direction: 'after', + cursor: subscriber.cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + if (!result.ok) { + const page = { ...result.page, fence: subscriber.fence } + this.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: result.reset, + page, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + return + } + const page = result.page + const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence + if (!advanced) { + if (handoff || emitCheckpoint) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + } + return + } + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + subscriber.cursor = page.window.nextCursor + if (!page.hasNewer || !this.isActive(subscriber)) { + return + } + } + } + + private isActive(subscriber: Subscriber): boolean { + return this.bySession.get(subscriber.sessionId)?.get(subscriber.id) === subscriber + } + + /** A dead transport cannot be allowed to turn a durable mutation into an + * unknown outcome or poison every later publication. */ + private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + try { + subscriber.emit(event) + } catch { + this.drop(subscriber) + } + } + + private drop(subscriber: Subscriber): void { + const session = this.bySession.get(subscriber.sessionId) + session?.delete(subscriber.id) + if (session?.size === 0) { + this.bySession.delete(subscriber.sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts new file mode 100644 index 00000000000..04170a3c840 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -0,0 +1,250 @@ +// The lifetime of a provider child, from the surfaces that hold the session. +// +// Two leaks meet here and each has to be tested against the real host, not a double: a chat that +// closes without stopping its app-server, and a launch that starts one for every record on disk. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const SURFACE = 'desktop-chat:1' +/** Short enough to keep the suite fast, long enough that an eviction is a decision and not a race. */ +const GRACE_MS = 5 + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let closeSession: Mock> +let sink: StructuredAgentSessionEventSink | null +let hostErrors: unknown[] +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + closeSession, + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } +} + +function openHost( + probeOwner?: (record: never) => Promise, + handoffTransport?: StructuredAgentSessionHandoffTransport +): void { + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + releaseGraceMs: GRACE_MS, + now: () => NOW, + onEventSinkError: ({ error }) => hostErrors.push(error), + ...(probeOwner ? { probeOwner: probeOwner as never } : {}), + ...(handoffTransport ? { handoffTransport } : {}) + }) +} + +/** A fresh app generation over the same durable store, with its owner proven gone. */ +async function reboot(): Promise { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + closeSession.mockClear() +} + +async function attach(): Promise { + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +} + +function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + ) +} + +/** Eviction is a sequence, not an event: the child stops first and the session is forgotten last. */ +function waitForEviction(): Promise { + return vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) +} + +/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ +function waitOutSeveralGraceWindows(): Promise { + return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) + resetHostTestOperationIds() + sink = null + hostErrors = [] + acquire = vi.fn(async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + } + }) + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat that closes', () => { + it('releases the provider child it was holding', async () => { + await attach() + await host.hold(SESSION, SURFACE) + + host.release(SESSION, SURFACE) + + await waitForEviction() + expect(hostErrors).toEqual([]) + // The record and its journal stay; only the process and the claim on it go. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'exit-observed' } + }) + }) + + it('keeps the child while another surface still holds the session', async () => { + await attach() + await host.hold(SESSION, SURFACE) + await host.hold(SESSION, 'paired-phone:1') + + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('does not lose the session to a release the client sent twice', async () => { + await attach() + await host.hold(SESSION, SURFACE) + await host.hold(SESSION, 'paired-phone:1') + + // A retried release must retire ONE holder, which is what a set gets right and a count does not. + host.release(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) +}) + +describe('a session with a turn in flight', () => { + it('is not evicted while the turn runs, and is once it ends', async () => { + await attach() + await host.hold(SESSION, SURFACE) + emitTurnLifecycle('running', 1) + await host.flushStreamedEvents(SESSION) + + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + + emitTurnLifecycle('completed', 2) + await host.flushStreamedEvents(SESSION) + + await waitForEviction() + }) +}) + +describe('startup', () => { + it('restores a session for reading without spawning a provider child', async () => { + await attach() + await reboot() + + await host.restoreReadableSessions() + + // The record is readable — the tab comes back, history answers — and nothing is running. + expect(acquire).not.toHaveBeenCalled() + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } + ]) + expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) + + it('gives the child back to a chat a surface actually opens', async () => { + await attach() + await reboot() + await host.restoreReadableSessions() + + await host.hold(SESSION, SURFACE) + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeKind: 'native', + ownerProcess: { pid: 4242 } + }) + }) +}) + +describe('a session evicted and opened again', () => { + it('publishes provider events to the reattached chat', async () => { + await attach() + await host.hold(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await waitForEviction() + + await host.hold(SESSION, 'desktop-chat:2') + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + ) + sink?.publish() + await host.flushStreamedEvents(SESSION) + unsubscribe() + + expect(JSON.stringify(events)).toContain('back again') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts new file mode 100644 index 00000000000..520db02570b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +function pendingChainCount(queue: StructuredAgentSessionTaskQueue): number { + return (queue as unknown as { chains: Map> }).chains.size +} + +describe('StructuredAgentSessionTaskQueue', () => { + it('deletes a successful settled tail', async () => { + const queue = new StructuredAgentSessionTaskQueue() + + await expect(queue.serialize('session-1', async () => 'done')).resolves.toBe('done') + await Promise.resolve() + + expect(pendingChainCount(queue)).toBe(0) + }) + + it('deletes a rejected settled tail without poisoning the next task', async () => { + const queue = new StructuredAgentSessionTaskQueue() + + await expect( + queue.serialize('session-1', async () => { + throw new Error('failed') + }) + ).rejects.toThrow('failed') + await expect(queue.serialize('session-1', async () => 'recovered')).resolves.toBe('recovered') + await Promise.resolve() + + expect(pendingChainCount(queue)).toBe(0) + }) + + it('does not let an earlier tail cleanup delete an overlapping replacement', async () => { + const queue = new StructuredAgentSessionTaskQueue() + const firstGate = Promise.withResolvers() + const secondGate = Promise.withResolvers() + const order: string[] = [] + const first = queue.serialize('session-1', async () => { + order.push('first-start') + await firstGate.promise + order.push('first-end') + }) + const second = queue.serialize('session-1', async () => { + order.push('second-start') + await secondGate.promise + order.push('second-end') + }) + + firstGate.resolve() + await first + expect(pendingChainCount(queue)).toBe(1) + await vi.waitFor(() => expect(order).toEqual(['first-start', 'first-end', 'second-start'])) + + secondGate.resolve() + await second + await Promise.resolve() + expect(order).toEqual(['first-start', 'first-end', 'second-start', 'second-end']) + expect(pendingChainCount(queue)).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts new file mode 100644 index 00000000000..f0237835c75 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -0,0 +1,25 @@ +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' + +export class StructuredAgentSessionTaskQueue { + private readonly chains = new Map>() + private readonly attaching = new Set>() + + serialize(sessionId: string, task: () => Promise): Promise { + return runKeyedSerializedOperation(this.chains, sessionId, task) + } + + trackAttach(operation: Promise): Promise { + this.attaching.add(operation) + void operation.then( + () => this.attaching.delete(operation), + () => this.attaching.delete(operation) + ) + return operation + } + + async drainAttaches(): Promise { + while (this.attaching.size > 0) { + await Promise.allSettled(this.attaching) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts new file mode 100644 index 00000000000..23a237311f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -0,0 +1,290 @@ +// The effects behind send / cancel / respond / setOption. +// +// Admission (lease, fence, idempotency) has already passed by the time anything +// here runs; these functions own only the journal writes and the adapter call, +// in that order. Journal first is deliberate: a crash between the two leaves a +// row the next attach settles as `unknown`, whereas the reverse would lose a +// turn the provider already accepted. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' +import type { + AgentSessionCancelResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +export type AgentSessionTurnContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + persistedOptions?: Readonly> + persistOptions: (options: Readonly>) => Promise + /** Opaque client identity recorded as the resolver of a prompt. */ + resolvedBy: string + publish: () => void + now: () => number +} + +export type TurnOutcome = + | { ok: true; value: TValue } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles + * as `unknown` rather than as a rejection. */ +async function dispatchSafely( + ctx: AgentSessionTurnContext, + clientMessageId: string, + body: AgentJournalMessageItem +): Promise { + try { + return await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + fence: ctx.fence + }) + } catch (error) { + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +async function appendStatus( + ctx: AgentSessionTurnContext, + clientMessageId: string, + text: string +): Promise { + await ctx.journal.appendItem( + { provider: 'orca', clientMessageId }, + { kind: 'status', text }, + { fence: ctx.fence } + ) + ctx.publish() +} + +export async function performSend( + ctx: AgentSessionTurnContext, + input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + retryUnknown?: true + } +): Promise> { + const existing = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { + return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + } + if (existing && !(input.retryUnknown && existing.dispatchState === 'unknown')) { + return { + ok: true, + value: { clientMessageId: input.clientMessageId, submission: existing } + } + } + if (!(input.retryUnknown && existing?.dispatchState === 'unknown')) { + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + ctx.publish() + } + + const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body) + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : { + clientMessageId: input.clientMessageId, + state: outcome.state, + reason: outcome.reason, + fence: ctx.fence + } + ) + ctx.publish() + + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + return { ok: true, value: { clientMessageId: input.clientMessageId, submission } } +} + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { clientOperationId: string; turnId: string } +): Promise> { + let cancelled = false + let note = 'Turn cancelled.' + try { + cancelled = ( + await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + turnId: input.turnId, + fence: ctx.fence + }) + ).cancelled + if (!cancelled) { + note = 'The provider had already finished this turn.' + } + } catch (error) { + note = `Cancellation was not confirmed: ${ + error instanceof Error ? error.message : String(error) + }` + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await appendStatus(ctx, input.clientOperationId, note) + return { ok: true, value: { turnId: input.turnId, cancelled } } +} + +function promptBodyOf(body: AgentJournalItemBody): { + options: readonly { id: string }[] + freeTextQuestionId?: string + resolution: AgentJournalResolution +} | null { + return body.kind === 'approval' || body.kind === 'question' ? body : null +} + +/** + * Durable compare-and-set on (itemId, revision) plus the pending state. The + * journal write commits before the provider callback fires, so two clients + * answering one prompt produce exactly one callback and the loser is told which + * answer won. + */ +export async function performPrompt( + ctx: AgentSessionTurnContext, + input: { + itemId: string + expectedRevision: number + optionId: string + kind: 'approval' | 'question' + } +): Promise> { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = promptBodyOf(item.body) + if (!prompt || item.body.kind !== input.kind) { + return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + const freeText = decodeCodexQuestionOptionId(input.optionId) + const acceptsFreeText = + item.body.kind === 'question' && + prompt.freeTextQuestionId !== undefined && + freeText?.questionId === prompt.freeTextQuestionId && + freeText.answer.trim().length > 0 + if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) { + return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + } + const identity = parseAgentJournalItemKey(input.itemId) + if (!identity) { + return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + } + + const resolution: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: input.optionId, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + const appended = await ctx.journal.appendItem( + identity, + { ...item.body, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + + try { + await ctx.adapter.answerPrompt({ + sessionId: ctx.sessionId, + itemId: input.itemId, + kind: input.kind, + optionId: input.optionId, + fence: ctx.fence + }) + } catch (error) { + // The answer is committed and will not be offered again; say so rather than + // reopening the prompt and risking a second callback. + await appendStatus( + ctx, + `${input.itemId}#delivery`, + `Your answer was recorded but the agent did not confirm it: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + return { + ok: true, + value: { itemId: appended.itemId, revision: appended.revision, resolution } + } +} + +/** Options live on the provider, not in the journal, so this writes nothing. */ +export async function performSetOption( + ctx: AgentSessionTurnContext, + input: { key: string; value: string } +): Promise> { + let applied: void | Readonly> + try { + applied = await ctx.adapter.setOption({ + sessionId: ctx.sessionId, + ...input, + fence: ctx.fence + }) + } catch (error) { + if (isAgentSessionOptionRejectedError(error)) { + return invalid(error.message) + } + throw error + } + await ctx.persistOptions(applied ?? { [input.key]: input.value }) + return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts new file mode 100644 index 00000000000..4af342227c2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -0,0 +1,334 @@ +// The profiles already shipped into a dead end. +// +// Every record here is a shape taken from a real wedged store: a lease that no acquisition, no +// handoff restore, and no manual recovery can move, so the chat behind it never opens again. The +// contract is that loading the record under this build makes it usable WITHOUT losing the +// conversation — the journal, the provider handle chain, and the recorded evidence all survive. +// +// "Usable" means ACQUIRABLE, not acquired. Startup no longer resumes a provider child for a record +// nobody is looking at; a surface taking a hold is what spawns one. So the migration's job is to +// leave the lease in a state a hold can claim, and these tests prove that by adjudicating it rather +// than by reading fields off it. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' +import type { + AgentSessionClaimStatus, + AgentSessionHandoffStage, + AgentSessionOwnerRuntimeKind, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const DEAD_OWNER: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 12_546, + processStartTimeMs: 1_786_772_085_000, + spawnToken: 'spawn-dead' +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock + +type WedgeOverrides = { + claimStatus: AgentSessionClaimStatus + handoffStage: AgentSessionHandoffStage | null + runtimeKind?: AgentSessionOwnerRuntimeKind + ownerProcess?: AgentSessionProcessIdentity | null + reservedSpawnToken?: string | null + handoffOperationId?: string | null +} + +/** A record in the wedged shape, with real history behind it. */ +function wedgedRecord(overrides: WedgeOverrides): AgentSessionRecord { + const fence = 13 + return { + schemaVersion: 2, + sessionId: SESSION, + location: LOCATION, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-${fence}-link`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW - 10_000 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + createdAt: NOW - 100_000, + updatedAt: NOW - 10_000, + lease: { + sessionId: SESSION, + runtimeKind: overrides.runtimeKind ?? 'native', + runtimeFence: fence, + handoffStage: overrides.handoffStage, + provenHandleLinkId: `codex-${fence}-link`, + ownerProcess: overrides.ownerProcess ?? null, + reservedSpawnToken: overrides.reservedSpawnToken ?? null, + leaseDeadlineAt: NOW - 9_000, + lastRenewedAt: NOW - 10_000, + handoffOperationId: overrides.handoffOperationId ?? null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: overrides.claimStatus, + unreconciled: false, + deathEvidence: null + } + } +} + +async function seedStore(record: AgentSessionRecord): Promise { + const directory = join(root, 'store') + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, AGENT_SESSION_STORE_FILE_NAME), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [record.sessionId]: record }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }), + 'utf-8' + ) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ +function openHost(overrides: Partial = {}): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => undefined), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + now: () => NOW, + probeOwner: async () => ({ outcome: 'pid-absent' }), + ...overrides + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-new' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } + })) +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** The property that matters: a hold taken now would be granted a lease. */ +function isAcquirable(lease: NonNullable>['lease']): boolean { + return ( + evaluateAgentSessionAcquisition({ + lease, + expectedFence: lease.runtimeFence, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }).decision === 'granted' + ) +} + +describe('already-wedged profiles become usable on load', () => { + it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => { + // A crash can leave a conflicted current-schema row in manual recovery; positive death proof + // must make it acquirable again without discarding the provider handle. + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost() + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + // A real re-adjudication, not a no-op: the eviction minted a new generation. + expect(lease?.runtimeFence).toBeGreaterThan(13) + // The conversation survived: the codex thread was resumed, not recreated. + expect(store.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ + linkId: 'codex-13-link', + handle: { threadId: THREAD } + }) + // Why NOT acquired here: startup spawning a provider child for every recovered record is the + // accumulation this stack removed. Unlatching is the migration's job; spawning is a hold's. + expect(acquire).not.toHaveBeenCalled() + }) + + it('leaves a conflicted record alone while its owner cannot be proven gone', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost({ + probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: { pid: DEAD_OWNER.pid } + }) + }) + + it('unlatches a released record that reloaded into recovery with nothing outstanding', async () => { + // An evicted lease has no owner and no token, so a restart has nothing to probe. Treating that + // as an unproven reservation re-latched it to `recovering` on every single boot. + await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })) + openHost() + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + }) + + it('exits a TUI reservation that crashed before its identity was committed', async () => { + // The reviewer's shape: a TUI child launched, the runtime died before `commitProcessIdentity`, + // and restart adjudication could not answer, so the lease latched at `recovering` with a null + // owner. Handoff restore cannot help (no owner to talk to) and manual recovery requires one, + // so recovery resolution is the ONLY exit — and it used to skip every TUI record. + await seedStore( + wedgedRecord({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeKind: 'tui', + reservedSpawnToken: 'spawn-tui', + handoffOperationId: 'handoff-op-1' + }) + ) + // Restart adjudication runs while the host still cannot enumerate the token; the later + // recovery pass gets a real answer. + let probes = 0 + openHost({ + probeOwner: async () => { + probes += 1 + return probes === 1 + ? { outcome: 'indeterminate', reason: 'host could not enumerate spawn tokens' } + : { outcome: 'reservation-unused' } + } + }) + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + }) + + it('does not infer orphan ownership from a host-global token scan', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + const order: string[] = [] + const scan = vi.fn( + async () => + new Map([ + ['spawn-lost', [31_337]], + [DEAD_OWNER.spawnToken, [12_546]] + ]) + ) + acquire.mockImplementation(async ({ fence }) => { + order.push('acquire') + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }) + openHost({ + scanSpawnTokenProcesses: scan, + stopOwnerProcess: (pid) => order.push(`stop:${pid}`) + }) + + await host.restoreReadableSessions() + expect(order).toEqual([]) + expect(scan).not.toHaveBeenCalled() + await host.hold(SESSION, 'holder-1') + + expect(order).toEqual(['acquire']) + }) + + it('names the missing evidence when a latched record still cannot be freed', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no answer' }) }) + await host.restoreReadableSessions() + + const refused = await host.attach(CALLER, hostTestAttachParams(13)) + + expect(refused.ok).toBe(false) + const message = refused.ok ? '' : refused.refusal.message + expect(message).toContain('process 12546 on local') + expect(message).not.toContain('The session store refused this call') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts new file mode 100644 index 00000000000..3e71b414cbc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -0,0 +1,160 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const SESSION = 'wire-admission-session' +const LARGE_TEXT = 'x'.repeat(250 * 1024) + +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-admission-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root, + autoCompact: false + }) + for (let ordinal = 1; ordinal <= 20; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + } +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured agent-session outbound admission', () => { + it('admits bounded initial, handoff, epoch, compaction, and history recovery frames', async () => { + expect(Buffer.byteLength(JSON.stringify(journal.snapshot()), 'utf8')).toBeGreaterThan( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + + const subscribers = new AgentSessionSubscribers() + const initial: AgentSessionSubscribeEvent[] = [] + const dispose = subscribers.open({ + id: 'initial', + sessionId: SESSION, + journal, + fence: 1, + emit: (event) => initial.push(event) + }) + expect(initial).toHaveLength(1) + expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) + expectAdmitted(initial[0]) + + subscribers.handoff(SESSION, 2, { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + }) + subscribers.snapshot(SESSION, journal, 2) + expect(initial.slice(1)).toHaveLength(2) + initial.slice(1).forEach(expectAdmitted) + + const epochReset: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'old-epoch', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: 'retired-epoch', sequence: 1 }, + emit: (event) => epochReset.push(event) + }) + expect(epochReset[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed' }) + expectAdmitted(epochReset[0]) + const epochHistory = readAgentSessionHistory(journal, { + sessionId: SESSION, + direction: 'before', + cursor: { epoch: 'retired-epoch', sequence: 1 } + }) + expect(epochHistory).toMatchObject({ ok: false, reset: 'epoch_changed' }) + expectAdmitted(epochHistory) + + await journal.compact(Date.now() + 1, { minTailRows: 0, retainTailMs: 0 }) + const compactedReset: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'compacted', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: journal.epoch, sequence: 0 }, + emit: (event) => compactedReset.push(event) + }) + expect(compactedReset[0]).toMatchObject({ type: 'reset', reset: 'cursor_compacted' }) + expectAdmitted(compactedReset[0]) + + const history = readAgentSessionHistory(journal, { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: journal.epoch, sequence: 0 } + }) + expect(history).toMatchObject({ ok: false, reset: 'cursor_compacted' }) + expectAdmitted(history) + expect(initial.some((event) => event.type === 'end')).toBe(false) + dispose() + }) + + it('splits valid-cursor catch-up into admitted batch frames', () => { + const subscribers = new AgentSessionSubscribers() + const catchup: AgentSessionSubscribeEvent[] = [] + const firstItemSequence = journal.snapshot().items[0]!.sequence + + subscribers.open({ + id: 'catchup', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: journal.epoch, sequence: firstItemSequence }, + emit: (event) => catchup.push(event) + }) + + expect(catchup.length).toBeGreaterThan(1) + catchup.forEach(expectAdmitted) + expect( + catchup.flatMap((event) => (event.type === 'batch' ? event.batch.items : [])) + ).toHaveLength(19) + expect(catchup.at(-1)).toMatchObject({ + type: 'batch', + batch: { cursor: journal.cursor() }, + fence: 2 + }) + }) +}) + +function expectAdmitted(value: unknown): void { + const frame = JSON.stringify({ id: 'request-1', result: value }) + const bytes = mobileE2EETextPayloadAdmissionBytes(frame) + expect(Number.isFinite(bytes)).toBe(true) + expect(bytes).toBeLessThanOrEqual(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts new file mode 100644 index 00000000000..ad211902433 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -0,0 +1,24 @@ +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' + +export type StructuredProviderSessionOwnership = { + sessionId: string + workspaceId: string + provider: 'claude' | 'codex' + providerSessionId: string + lease: AgentSessionLease +} + +export function listStructuredProviderSessionOwnership( + records: readonly AgentSessionRecord[] +): StructuredProviderSessionOwnership[] { + return records.flatMap((record) => + record.providerHandleChain.map((link) => ({ + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: + link.handle.provider === 'codex' ? link.handle.threadId : link.handle.sessionId, + lease: record.lease + })) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts new file mode 100644 index 00000000000..704f05fad60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts @@ -0,0 +1,60 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' + +const BOUNDARY_FILE = 'structured-tui-transcript-boundary.json' +const BOUNDARY_SCHEMA_VERSION = 1 + +export type StructuredTuiTranscriptBoundary = { + providerSessionId: string + runtimeFence: number + filePath: string | null + offset: number +} + +function boundaryPath(journalDirectory: string): string { + return join(journalDirectory, BOUNDARY_FILE) +} + +export async function readStructuredTuiTranscriptBoundary( + journalDirectory: string +): Promise { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(boundaryPath(journalDirectory), 'utf8')) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const value = parsed as Record + if ( + value.schemaVersion !== BOUNDARY_SCHEMA_VERSION || + typeof value.providerSessionId !== 'string' || + !Number.isSafeInteger(value.runtimeFence) || + (value.filePath !== null && typeof value.filePath !== 'string') || + !Number.isSafeInteger(value.offset) || + (value.offset as number) < 0 + ) { + return null + } + return { + providerSessionId: value.providerSessionId, + runtimeFence: value.runtimeFence as number, + filePath: value.filePath as string | null, + offset: value.offset as number + } +} + +export async function writeStructuredTuiTranscriptBoundary( + journalDirectory: string, + boundary: StructuredTuiTranscriptBoundary +): Promise { + const filePath = boundaryPath(journalDirectory) + await writeFileDurable( + durableWriteTempPath(filePath), + filePath, + JSON.stringify({ schemaVersion: BOUNDARY_SCHEMA_VERSION, ...boundary }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts new file mode 100644 index 00000000000..b94d671fa9a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts @@ -0,0 +1,162 @@ +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-catchup' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let store: AgentSessionRecordStore + +function rolloutLine(message: string): string { + return `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-11T10:00:00.000Z', + payload: { type: 'agent_message', message } + })}\n` +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-tui-catchup-')) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function createCatchupFixture() { + const accountHome = join(root, 'isolated-codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') + const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile(rollout, rolloutLine('before handoff'), 'utf8') + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: accountHome }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'tui-token', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'initial' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'tui-token' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + return { fence, journal, rollout } +} + +function createCatchup(input: Awaited>) { + return new StructuredTuiTranscriptCatchup({ + store, + session: () => ({ + hasProviderChild: false, + journal: input.journal, + params: {} as never, + fence: input.fence + }), + schedule: async (_sessionId, task) => task(), + publish: vi.fn(), + reset: vi.fn() + }) +} + +describe('StructuredTuiTranscriptCatchup', () => { + it('tails only TUI-era appends from the durable account home', async () => { + const fixture = await createCatchupFixture() + const catchup = createCatchup(fixture) + + await catchup.prepare(SESSION, fixture.fence) + await catchup.activate(SESSION) + expect(fixture.journal.snapshot().items).toEqual([]) + + await appendFile(fixture.rollout, rolloutLine('during TUI'), 'utf8') + await vi.waitFor(() => + expect(fixture.journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'during TUI' }] + }) + ) + + catchup.stop(SESSION) + await appendFile(fixture.rollout, rolloutLine('after stop'), 'utf8') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(fixture.journal.snapshot().items).toHaveLength(1) + }) + + it('replays transcript writes made while the host watcher was down', async () => { + const fixture = await createCatchupFixture() + const beforeCrash = createCatchup(fixture) + await beforeCrash.prepare(SESSION, fixture.fence) + await beforeCrash.activate(SESSION) + await appendFile(fixture.rollout, rolloutLine('before host crash'), 'utf8') + await vi.waitFor(() => expect(fixture.journal.snapshot().items).toHaveLength(1)) + beforeCrash.stopAll() + + await appendFile(fixture.rollout, rolloutLine('while host was down'), 'utf8') + const recovered = createCatchup(fixture) + await recovered.recover(SESSION, fixture.fence) + await recovered.activate(SESSION) + + const text = fixture.journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + expect(text).toEqual(['before host crash', 'while host was down']) + recovered.stopAll() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts new file mode 100644 index 00000000000..cc343c9231c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts @@ -0,0 +1,266 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + appendLegacyTranscriptMessages, + importLegacyTranscriptIntoJournal +} from '../agent-session-journal/journal-legacy-import' +import { resolveSessionFilePath } from '../session-file-resolver' +import { + readIncrementalTranscriptMessages, + type IncrementalTranscriptState +} from '../transcript-incremental-reader' +import { nativeChatLineDecoderForAgent } from '../transcript-tail-reader' +import { + subscribeNativeChatTranscript, + type NativeChatTranscriptSubscription +} from '../transcript-watch' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + readStructuredTuiTranscriptBoundary, + writeStructuredTuiTranscriptBoundary +} from './structured-tui-transcript-boundary' + +type CatchupState = { + active: boolean + fence: number + agent: AgentSessionHandleProvider + providerSessionId: string + pending: NativeChatMessage[] + seen: Set + subscription: NativeChatTranscriptSubscription | null +} + +export class StructuredTuiTranscriptCatchup { + private readonly states = new Map() + + constructor( + private readonly input: { + store: AgentSessionRecordStore + session: (sessionId: string) => StructuredAgentSessionHostSession + schedule: (sessionId: string, task: () => Promise) => Promise + publish: (sessionId: string) => void + reset: (sessionId: string, fence: number) => void + onError?: (input: { sessionId: string; error: unknown }) => void + } + ) {} + + async prepare(sessionId: string, fence: number): Promise { + await this.start(sessionId, fence, false) + } + + async recover(sessionId: string, fence: number): Promise { + await this.start(sessionId, fence, true) + } + + private async start(sessionId: string, fence: number, recovering: boolean): Promise { + this.stop(sessionId) + const record = this.input.store.getRecord(sessionId) + const head = record?.providerHandleChain.at(-1) + if ( + !record || + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { + return + } + const agent = head.handle.provider + const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId + const journal = this.input.session(sessionId).journal + const transcriptOptions = + agent === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } + const boundary = recovering + ? await readStructuredTuiTranscriptBoundary(journal.directory) + : null + const filePath = await resolveSessionFilePath(agent, providerSessionId, { + ...transcriptOptions, + ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) + }) + let initialReady: (() => void) | null = null + let baselineOffset = 0 + const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null + const state: CatchupState = { + active: false, + fence, + agent, + providerSessionId, + pending: [], + seen: new Set(), + subscription: null + } + const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) + this.states.set(sessionId, state) + try { + state.subscription = await subscribeNativeChatTranscript({ + agent, + sessionId: providerSessionId, + ...transcriptOptions, + ...(filePath ? { filePath, initialLimit: 0 } : {}), + onInitialSnapshot: (messages, _hasMore, beforeOffset) => { + baselineOffset = beforeOffset + receive(messages) + initialReady?.() + initialReady = null + }, + onAppend: receive + }) + await ready + if (!recovering) { + await writeStructuredTuiTranscriptBoundary(journal.directory, { + providerSessionId, + runtimeFence: fence, + filePath, + offset: baselineOffset + }) + } else if ( + filePath && + boundary?.providerSessionId === providerSessionId && + boundary.runtimeFence === fence && + boundary.filePath === filePath + ) { + await this.readRecoveryGap(sessionId, state, filePath, boundary.offset) + } else if (filePath) { + const imported = await importLegacyTranscriptIntoJournal({ + journal, + agent, + sessionId: providerSessionId, + fence, + options: { filePath, decodedMessageIdentities: true } + }) + if (!imported.ok) { + throw new Error(imported.error) + } + this.input.reset(sessionId, fence) + } + } catch (error) { + if (this.states.get(sessionId) === state) { + this.states.delete(sessionId) + } + state.subscription?.unsubscribe() + throw error + } + } + + private async readRecoveryGap( + sessionId: string, + state: CatchupState, + filePath: string, + offset: number + ): Promise { + let size: number + try { + size = (await stat(filePath)).size + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + const start = offset <= size ? offset : 0 + const incremental: IncrementalTranscriptState = { + offset: start, + pendingChunks: [], + pendingStart: start, + pendingBytes: 0, + droppingOversizedRecord: false + } + const decode = nativeChatLineDecoderForAgent(state.agent) + if (!decode) { + throw new Error('Transcript unavailable') + } + let messages: NativeChatMessage[] + try { + messages = await readIncrementalTranscriptMessages(filePath, incremental, decode) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + this.receive(sessionId, state, messages) + } + + async activate(sessionId: string): Promise { + const state = this.states.get(sessionId) + if (!state) { + return + } + state.active = true + const pending = state.pending.splice(0) + await this.append(sessionId, state, pending) + } + + stop(sessionId: string): void { + const state = this.states.get(sessionId) + this.states.delete(sessionId) + state?.subscription?.unsubscribe() + } + + stopAll(): void { + for (const sessionId of this.states.keys()) { + this.stop(sessionId) + } + } + + private receive(sessionId: string, state: CatchupState, messages: NativeChatMessage[]): void { + if (this.states.get(sessionId) !== state) { + return + } + if (!state.active) { + state.pending.push(...messages) + return + } + void this.input + .schedule(sessionId, () => this.append(sessionId, state, messages)) + .catch((error) => this.input.onError?.({ sessionId, error })) + } + + private async append( + sessionId: string, + state: CatchupState, + messages: NativeChatMessage[] + ): Promise { + if (this.states.get(sessionId) !== state) { + return + } + const record = this.input.store.getRecord(sessionId) + if ( + !record || + record.lease.runtimeKind !== 'tui' || + record.lease.claimStatus !== 'live' || + record.lease.runtimeFence !== state.fence + ) { + return + } + const ids = new Set(state.seen) + const fresh = messages.filter((message) => { + if (ids.has(message.id)) { + return false + } + ids.add(message.id) + return true + }) + if (fresh.length === 0) { + return + } + try { + await appendLegacyTranscriptMessages({ + journal: this.input.session(sessionId).journal, + agent: state.agent, + sessionId: state.providerSessionId, + fence: state.fence, + messages: fresh + }) + for (const message of fresh) { + state.seen.add(message.id) + } + this.input.publish(sessionId) + } catch (error) { + this.input.onError?.({ sessionId, error }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts new file mode 100644 index 00000000000..d287838a13c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' +import { projectStructuredItemToNativeChat } from '../../../shared/structured-agent-session-projection' +import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' + +describe('unhandled provider frame journal fallback', () => { + it('keeps a compact label and bounds the expandable payload without dropping it', () => { + const item = unhandledProviderFrameJournalItem( + 'future-provider', + 'notification:new/event', + { body: 'abcdefghij' }, + { + inlineHeadBytes: 8, + maxSessionBytes: 1024, + maxAppendsPerWindow: 10, + appendWindowMs: 1000 + } + ) + + expect(item).not.toBeNull() + if (!item) { + throw new Error('expected substantive provider frame') + } + expect(item.body).toMatchObject({ + kind: 'status', + text: 'future-provider · notification:new/event', + providerFrame: { + provider: 'future-provider', + kind: 'notification:new/event', + payload: { byteLength: 21, truncated: true } + } + }) + expect( + Buffer.byteLength(item.body.providerFrame?.payload.head ?? '', 'utf8') + ).toBeLessThanOrEqual(8) + expect(item.blobs).toEqual([ + { + digest: item.body.providerFrame?.payload.digest, + payload: '{"body":"abcdefghij"}' + } + ]) + }) + + it('turns an unserializable message-shaped payload into an explicit visible value', () => { + const cyclic: { warning?: unknown } = {} + cyclic.warning = cyclic + + const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic) + + expect(item?.body.text).toBe('codex · frame') + expect(item?.body.providerFrame?.payload.head).toContain('unserializable payload') + }) + + it('routes provider lifecycle, startup, and status frames away from the timeline', () => { + expect(unhandledProviderFrameJournalItem('codex', 'notification:thread/started', {})).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:remoteControl/status/changed', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {}) + ).toBeNull() + expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull() + expect( + unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'success', + is_error: false + }) + ).toBeNull() + }) + + it('never creates generic rows for delta-shaped frames that report no failure', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:item/commandExecution/outputDelta', { + itemId: 'exec-1', + delta: 'x' + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { + itemId: 'future-1', + delta: 'y' + }) + ).toBeNull() + }) + + it('surfaces an unknown delta-shaped frame whose payload reports an error', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { + error: 'stream broke mid-item' + }) + + expect(row).not.toBeNull() + expect(row?.classification).toBe('error-surface') + expect(row?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:item/future/outputDelta' + }) + }) + + it('renders codex systemError and Claude error result variants', () => { + const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', { + threadId: 'thread-1', + status: { type: 'systemError' } + }) + const claude = unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'error_during_execution', + is_error: true, + result: 'Provider request failed' + }) + + expect(codex?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:thread/status/changed' + }) + expect(claude?.body.providerFrame).toMatchObject({ + provider: 'claude', + kind: 'message:result' + }) + expect( + claude + ? projectStructuredItemToNativeChat({ + itemId: 'claude-error', + revision: 1, + sequence: 1, + observedAt: 1, + body: claude.body + }) + : null + ).toMatchObject({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ kind: 'message:result' }) + }) + ] + }) + }) + + it('keeps failed startup variants visible while suppressing startup progress', () => { + const kind = 'notification:mcpServer/startupStatus/updated' + + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'starting', + error: null, + failureReason: null + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'failed', + error: 'server exited', + failureReason: null + }) + ).not.toBeNull() + }) + + it('surfaces a failed hook completion while suppressing successful hook lifecycle', () => { + const kind = 'notification:hook/completed' + + expect( + unhandledProviderFrameJournalItem('codex', kind, { + run: { id: 'hook-1', status: 'completed' } + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', kind, { + run: { id: 'hook-1', status: 'failed' } + }) + ).not.toBeNull() + }) + + it('keeps unknown substantive frames visible for both providers', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:future/event', {}) + ).not.toBeNull() + expect(unhandledProviderFrameJournalItem('claude', 'message:future/event', {})).not.toBeNull() + }) + + it('leads with the provider sentence instead of naming the opcode', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:warning', { + message: 'Your plan limit resets in 2 hours.' + }) + expect(row?.body.text).toBe('Your plan limit resets in 2 hours.') + // The raw frame stays available behind the row's disclosure. + expect(row?.body.providerFrame?.kind).toBe('notification:warning') + }) + + it('bounds a provider sentence inline', () => { + const message = 'abcdefghij' + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:warning', + { message }, + { + inlineHeadBytes: 8, + maxSessionBytes: 1024, + maxAppendsPerWindow: 10, + appendWindowMs: 1000 + } + ) + + expect(row?.body.text).toContain('abcdefgh') + expect(row?.body.text).toContain('[Orca: output truncated') + }) + + it('unwraps a nested sentence and falls back to the opcode when there is none', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:warning', { + warning: { text: 'Sandbox is degraded.' } + })?.body.text + ).toBe('Sandbox is degraded.') + expect( + unhandledProviderFrameJournalItem('codex', 'notification:future/event', { count: 3 })?.body + .text + ).toBe('codex \u00b7 notification:future/event') + }) +}) + +describe('a failed provider dependency', () => { + it('leads with the failure the provider reported, not the method name', () => { + const item = unhandledProviderFrameJournalItem( + 'codex', + 'notification:mcpServer/startupStatus/updated', + { + threadId: 'thread-1', + name: 'codex_apps', + status: 'failed', + error: 'MCP client for `codex_apps` failed to start: authentication token invalidated', + failureReason: 'reauthenticationRequired' + } + ) + expect(item?.classification).toBe('error-surface') + expect(item?.body.text).toContain('failed to start') + expect(item?.body.text).not.toContain('notification:mcpServer') + }) + + it('stays out of the timeline while the dependency is merely starting', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', { + threadId: 'thread-1', + name: 'codex_apps', + status: 'starting' + }) + ).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts new file mode 100644 index 00000000000..b4651cfc952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -0,0 +1,104 @@ +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + type JournalPayloadLimits +} from '../agent-session-journal/journal-payload-bounds' +import { classifyProviderFrame } from './provider-frame-disposition' + +export type UnhandledProviderFrameJournalItem = { + body: AgentJournalStatusItem + blobs: { digest: string; payload: string }[] + /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ + classification: 'timeline-substantive' | 'error-surface' +} + +function serializeProviderPayload(payload: unknown): string { + try { + const serialized = JSON.stringify(payload) + return serialized === undefined ? String(payload) : serialized + } catch (error) { + return `[unserializable payload: ${error instanceof Error ? error.message : String(error)}]` + } +} + +/** Fields providers use for the human-facing sentence on a frame, most specific + * first. Nested one level because warnings arrive wrapped as often as not. */ +const MESSAGE_KEYS = [ + 'message', + 'text', + 'warning', + 'detail', + 'description', + 'reason', + // `error` is how a failed dependency reports itself — an MCP server that could not start says + // so here and nowhere else. Without it the row falls back to the bare method name, which is how + // "MCP server X failed to start: auth expired" reached users as `notification:mcpServer/...`. + 'error' +] as const + +function directReadableMessage(payload: unknown): string | null { + if (typeof payload === 'string') { + return payload.trim() || null + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return null + } + const record = payload as Record + for (const key of MESSAGE_KEYS) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + return null +} + +function readableMessage(payload: unknown): string | null { + const direct = directReadableMessage(payload) + if (direct || typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return direct + } + const record = payload as Record + for (const key of MESSAGE_KEYS) { + const nested = directReadableMessage(record[key]) + if (nested) { + return nested + } + } + return null +} + +/** Substantive adapter fallbacks become visible, bounded journal rows. */ +export function unhandledProviderFrameJournalItem( + provider: string, + kind: string, + payload: unknown, + limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS +): UnhandledProviderFrameJournalItem | null { + const classification = classifyProviderFrame(provider, kind, payload) + if ( + classification === 'stream-into-item' || + classification === 'status-chrome' || + classification === 'suppressed-benign' + ) { + return null + } + const serialized = serializeProviderPayload(payload) + const bounded = boundPayload(serialized, limits) + // Why: the opcode alone ("codex · notification:warning") tells the user nothing + // and reads as protocol noise. Lead with the provider's own sentence when it has + // one; the raw frame stays behind the row's disclosure either way. + const message = readableMessage(payload) + const display = message ? boundInlineText(message, limits) : null + return { + body: { + kind: 'status', + text: display?.text ?? `${provider} · ${kind}`, + providerFrame: { provider, kind, payload: bounded } + }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : [], + classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive' + } +} diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 98aa1f4abad..584d8a25a9d 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { resolveSessionFilePath } from './session-file-resolver' +import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { readClaudeTranscriptLeafUuid, resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -27,6 +28,117 @@ function restoreEnv(key: string, previous: string | undefined): void { } describe('resolveSessionFilePath', () => { + it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'leaf-current', + parentUuid: 'leaf-old', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( + 'leaf-current' + ) + }) + + it('fails closed when a Claude transcript has no branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'missing last-prompt marker' + ) + }) + + it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') + const transcript = join(root, 'session.jsonl') + await writeFile(transcript, '{"type":"last-prompt"', 'utf8') + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + + await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + }) + + it('refuses a Claude marker on a sibling branch', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( + 'sibling branch' + ) + }) + + it('refuses missing and cyclic Claude parent chains', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') + const missing = join(root, 'missing.jsonl') + const cycle = join(root, 'cycle.jsonl') + await writeFile( + missing, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + await writeFile( + cycle, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, + { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( + 'missing ancestor absent' + ) + await expect(readClaudeTranscriptLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( + 'cycle in parentUuid ancestry' + ) + }) + it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index a45850f2d46..aa3d781fb2d 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -17,6 +17,7 @@ import { import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -123,6 +124,21 @@ export async function resolveSessionFilePath( return resolved } +/** Read and validate Claude's authoritative transcript branch marker. */ +export async function readClaudeTranscriptLeafUuid( + transcriptPath: string, + providerSessionId: string, + previousLeafUuid: string | null = null +): Promise { + return ( + await proveClaudeTranscriptBranch({ + transcriptPath, + providerSessionId, + previousLeafUuid + }) + ).leafUuid +} + async function resolveSessionFileById( transcriptAgent: NativeChatTranscriptAgent, sessionId: string, diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index c6a5ff3adda..a7a86662809 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' +import { AgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices { return { @@ -232,3 +233,34 @@ describe('terminal.sendText explicit worktree routing', () => { expect(delegate.listTerminals).toHaveBeenCalledTimes(1) }) }) + +describe('terminal.sendText under a refusing agent-session lease', () => { + it('reports who holds the session instead of an accepted-looking result', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:one']) + vi.mocked(delegate.sendTerminal).mockRejectedValue( + new AgentSessionPtyWriteRefusedError({ + code: 'agent_session_conflict', + sessionId: 'session-alpha-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + }) + ) + + const outcome = await sendTerminalText(services, 'terminal:local:one') + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(outcome.ok ? '' : outcome.error).toContain('session-alpha-1') + expect(outcome.ok ? '' : outcome.error).toContain('native chat') + }) + + it('sends unchanged when no lease refuses, which is every plugin send today', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:one']) + + const outcome = await sendTerminalText(services, 'terminal:local:one') + + expect(outcome).toEqual({ ok: true, value: { accepted: true } }) + expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..10dd2b87e56 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,10 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import { + describeAgentSessionPtyWriteRefusal, + isAgentSessionPtyWriteRefusedError +} from '../../shared/agent-session-pty-write-admission' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -59,8 +63,17 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) - return { accepted: result.accepted } + try { + const result = await delegate.sendTerminal(terminalId, action) + return { accepted: result.accepted } + } catch (error) { + // Why: the plugin API carries only `accepted`, so a lease refusal would read as a silent + // drop; restate it as the message idiom plugin methods already surface to callers. + if (isAgentSessionPtyWriteRefusedError(error)) { + throw new Error(describeAgentSessionPtyWriteRefusal(error.refusal)) + } + throw error + } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), storage: { diff --git a/src/main/providers/agent-foreground-process-pi.test.ts b/src/main/providers/agent-foreground-process-pi.test.ts index e5f92038f8d..691ee63e748 100644 --- a/src/main/providers/agent-foreground-process-pi.test.ts +++ b/src/main/providers/agent-foreground-process-pi.test.ts @@ -21,7 +21,7 @@ describe('Pi Windows foreground recognition', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index 76de68fa0e8..d1784318df5 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -80,7 +80,7 @@ describe('resolveAgentForegroundProcess', () => { // Why: the Windows rows reader caches across calls (500ms TTL), so each // case's rows must not be answered by the previous case's snapshot. __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) platform = Object.getOwnPropertyDescriptor(process, 'platform') diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index a34746a6267..4700ab71059 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -4,6 +4,8 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type PtyProcessInfo = { id: string incarnationId?: PtyIncarnationId + /** Root process owned by this exact PTY incarnation, when the provider can prove it. */ + rootProcessId?: number cwd: string title: string /** Owning worktree when the provider can report it authoritatively. */ diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index bca3bf4fa18..3ba9ca79456 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -60,6 +60,8 @@ export class PtyProcessListAdmission { worktreeIdBytes === null || terminalHandleBytes === null || wslDistroBytes === null || + (value.rootProcessId !== undefined && + (!Number.isSafeInteger(value.rootProcessId) || value.rootProcessId <= 0)) || (value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) || (value.agentSessionOwners !== undefined && !Array.isArray(value.agentSessionOwners)) ) { @@ -108,6 +110,7 @@ export class PtyProcessListAdmission { cwd: value.cwd, title: value.title, ...(value.incarnationId !== undefined ? { incarnationId: value.incarnationId } : {}), + ...(value.rootProcessId !== undefined ? { rootProcessId: value.rootProcessId } : {}), ...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}), ...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}), ...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}), diff --git a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts index 6362f01f310..de6ffa94f6d 100644 --- a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts +++ b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts @@ -62,7 +62,7 @@ describe('windows agent foreground inspection process-table scan volume', () => platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) vi.useFakeTimers({ toFake: ['Date'] }) diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 3fa694e27b7..924c81789ce 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -53,7 +53,7 @@ describe('windows process rows', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts new file mode 100644 index 00000000000..c0ed223704a --- /dev/null +++ b/src/main/pty-descendant-exit-verification.ts @@ -0,0 +1,78 @@ +import { + DESCENDANT_KILL_GRACE_MS, + DESCENDANT_SNAPSHOT_TIMEOUT_MS, + hasUnambiguousStartIdentity, + readProcessTable, + readProcessTableBeforeDeadline, + sendDescendantSignal, + type DescendantSnapshot, + type ProcessTableRow, + type TerminateDeps +} from './pty-descendant-termination' + +export const DESCENDANT_KILL_VERIFY_MS = 3_500 + +function waitForDelay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref?.() + }) +} + +function matchingSnapshotRows( + snapshot: DescendantSnapshot, + table: readonly ProcessTableRow[] +): ProcessTableRow[] { + const expected = new Map(snapshot.descendants.map((row) => [row.pid, row])) + return table.filter((live) => { + const row = expected.get(live.pid) + return row?.startedAt === live.startedAt && row.pgid === live.pgid + }) +} + +type VerificationDeps = TerminateDeps & { + verifyMs?: number +} + +/** An unreadable process table is never proof that a stopped descendant exited. */ +export async function terminateDescendantSnapshotAndWait( + snapshot: DescendantSnapshot, + deps: VerificationDeps = {} +): Promise { + const sendSignal = deps.sendSignal ?? sendDescendantSignal + const readTable = deps.readTable ?? readProcessTable + const graceMs = deps.graceMs ?? DESCENDANT_KILL_GRACE_MS + const verifyMs = deps.verifyMs ?? DESCENDANT_KILL_VERIFY_MS + const deadline = Date.now() + verifyMs + for (const row of snapshot.descendants) { + sendSignal(row.pid, 'SIGTERM') + } + let forced = false + while (Date.now() < deadline) { + const capture = await readProcessTableBeforeDeadline( + readTable, + deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + ) + if (!capture) { + return false + } + const live = matchingSnapshotRows(snapshot, capture.rows) + if (live.length === 0) { + return true + } + if (!forced && Date.now() >= deadline - verifyMs + graceMs) { + forced = true + for (const row of live) { + if (hasUnambiguousStartIdentity(row, snapshot.capturedAtMs)) { + sendSignal(row.pid, 'SIGKILL') + } + } + } + await waitForDelay(50) + } + const finalCapture = await readProcessTableBeforeDeadline( + readTable, + deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + ) + return finalCapture !== null && matchingSnapshotRows(snapshot, finalCapture.rows).length === 0 +} diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index 85950d8e738..e1255a678d8 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -15,6 +15,7 @@ import { type ProcessTableCapture, type ProcessTableRow } from './pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from './pty-descendant-exit-verification' const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') @@ -299,6 +300,49 @@ describe('terminateDescendantSnapshot', () => { }) }) +describe('terminateDescendantSnapshotAndWait', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('escalates an identity-matched survivor and verifies its exit', async () => { + const survivor = row(20, 10, 20) + const sendSignal = vi.fn() + const readTable = vi + .fn() + .mockResolvedValueOnce(tableCapture([survivor])) + .mockResolvedValueOnce(tableCapture([])) + + const pending = terminateDescendantSnapshotAndWait(snapshot([survivor]), { + sendSignal, + readTable, + graceMs: 0, + verifyMs: 200 + }) + await vi.advanceTimersByTimeAsync(50) + + await expect(pending).resolves.toBe(true) + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [20, 'SIGKILL'] + ]) + }) + + it('does not claim exit when the verification table is unavailable', async () => { + const sendSignal = vi.fn() + const result = await terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { + sendSignal, + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')) + }) + + expect(result).toBe(false) + expect(sendSignal).toHaveBeenCalledWith(20, 'SIGTERM') + }) +}) + describe('createProcessTableSnapshotReader', () => { it('coalesces same-turn teardown requests onto one fresh scan', async () => { const capture = tableCapture([row(10, 1, 10)]) diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index cb39f8ae027..c91bbdd9a20 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -120,9 +120,9 @@ export function createProcessTableSnapshotReader( } } -const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable) +export const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable) -function readProcessTableBeforeDeadline( +export function readProcessTableBeforeDeadline( readTable: ProcessTableReader, timeoutMs: number ): Promise { @@ -298,7 +298,7 @@ export async function killWithDescendantSweep( } } -function defaultSendSignal(pid: number, signal: NodeJS.Signals): void { +export function sendDescendantSignal(pid: number, signal: NodeJS.Signals): void { try { process.kill(pid, signal) } catch { @@ -306,14 +306,14 @@ function defaultSendSignal(pid: number, signal: NodeJS.Signals): void { } } -type TerminateDeps = { +export type TerminateDeps = { readTable?: ProcessTableReader sendSignal?: SignalSender graceMs?: number timeoutMs?: number } -function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { +export function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { const startedAtMs = Date.parse(row.startedAt) if (!Number.isFinite(startedAtMs)) { return false @@ -333,7 +333,7 @@ export function terminateDescendantSnapshot( snapshot: DescendantSnapshot, deps: TerminateDeps = {} ): void { - const sendSignal = deps.sendSignal ?? defaultSendSignal + const sendSignal = deps.sendSignal ?? sendDescendantSignal const readTable = deps.readTable ?? readProcessTable for (const row of snapshot.descendants) { sendSignal(row.pid, 'SIGTERM') diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts new file mode 100644 index 00000000000..7ad20397813 --- /dev/null +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -0,0 +1,144 @@ +import { + agentSessionOperationKey, + settleAgentSessionOperation, + type AgentSessionOperationOutcome +} from '../../shared/agent-session-operation-ledger' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { assertFence, withLease } from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionFailedAcquisitionSettlement = { + sessionId: string + fence: number + spawnToken: string + callerKey: string + operationId: string + outcome: Extract + exitProof: 'exit-proven' | 'processless' | 'unproven' + now: number +} + +export type AgentSessionFailedPostAcquisitionAttachmentSettlement = + AgentSessionFailedAcquisitionSettlement + +/** Liveness invariant: a settled attach never leaves its reservation in new-owner-proving. */ +export function settleFailedAgentSessionAcquisition( + state: AgentSessionStoreState, + args: AgentSessionFailedAcquisitionSettlement +): AgentSessionRecord { + const operation = state.operations.get(agentSessionOperationKey(args.callerKey, args.operationId)) + if (!operation || operation.outcome.status !== 'pending') { + throw new Error('agent_session_operation_conflict') + } + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const next = settleFailedLease(record, args) + state.records.set(args.sessionId, next) + state.operations = settleAgentSessionOperation(state.operations, args) + return next +} + +/** A proved native owner still is not publishable until its journal attaches. */ +export function settleFailedAgentSessionPostAcquisitionAttachment( + state: AgentSessionStoreState, + args: AgentSessionFailedPostAcquisitionAttachmentSettlement +): AgentSessionRecord { + const operation = state.operations.get(agentSessionOperationKey(args.callerKey, args.operationId)) + if (!operation || operation.outcome.status !== 'pending') { + throw new Error('agent_session_operation_conflict') + } + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + assertFence(record.lease, args.fence) + if ( + record.lease.runtimeKind !== 'native' || + record.lease.claimStatus !== 'live' || + record.lease.handoffStage !== null || + record.lease.ownerProcess?.spawnToken !== args.spawnToken || + record.lease.reservedSpawnToken !== args.spawnToken || + record.lease.provenHandleLinkId === null + ) { + throw new Error('agent_session_ownership_unknown') + } + const next = + args.exitProof === 'unproven' + ? withLease(record, { + ...record.lease, + handoffStage: 'recovering', + handoffOperationId: null, + lastRenewedAt: args.now + }) + : withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: { + kind: 'exit-observed', + detail: 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now + } + }) + state.records.set(args.sessionId, next) + state.operations = settleAgentSessionOperation(state.operations, args) + return next +} + +function settleFailedLease( + record: AgentSessionRecord, + args: AgentSessionFailedAcquisitionSettlement +): AgentSessionRecord { + assertFence(record.lease, args.fence) + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.reservedSpawnToken !== args.spawnToken || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + if (args.exitProof === 'unproven') { + return withLease(record, { + ...record.lease, + handoffStage: record.lease.ownerProcess ? 'recovering' : 'manual-recovery', + // The operation is durably settled failed below; a lease still naming it would + // read as an in-flight transfer to every consumer that keys on the stage + id pair. + handoffOperationId: null, + lastRenewedAt: args.now + }) + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: + args.exitProof === 'processless' + ? { + kind: 'pid-absent', + detail: 'reservation failed before spawn', + observedAt: args.now + } + : { + // Cleanup proved no child of this attempt remains; it may never have spawned. + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts new file mode 100644 index 00000000000..88c4ad8d720 --- /dev/null +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -0,0 +1,43 @@ +// Recovering the agent-session store from its backup, without minting a second writer. +// +// The backup is the previous committed generation. The commit that never landed may have granted a +// fence one higher than anything the backup records show, and `isAgentSessionFenceCurrent` compares +// with STRICT EQUALITY — so a next-fence of `recordFence + 1` would *equal* that lost grant and +// accept a writer holding it. `+2` strictly dominates it. +// +// The bound "one lost commit can advance a session's fence by at most 1" is what makes +2 enough. +// It holds because every mint site routes through `nextAgentSessionFence` and each performs one +// transition per transaction, and because the save path aborts rather than letting the primary +// advance past a stale backup. A batching refactor would break it silently, so it is pinned by a +// test. +// +// This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current +// fence is what an earlier version did, and it corrupted exactly the records it meant to save: a +// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by +// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline — +// made the record invalid, quarantined it on the next load, and dropped back to the same backup. +// +// Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), +// `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process +// that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading +// already marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe +// once transactions are admitted. Nulling that evidence is how you get two writers on one provider +// session; the fence protects the store, not the provider session. + +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +/** Strictly above any fence the lost commit could have granted for that session. */ +export const AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN = 2 + +export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { + for (const [sessionId, record] of state.records) { + const floor = record.lease.runtimeFence + AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN + state.records.set(sessionId, { + ...record, + lease: { + ...record.lease, + minimumNextFence: Math.max(floor, record.lease.minimumNextFence ?? 0) + } + }) + } +} diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts new file mode 100644 index 00000000000..18d1ec00bda --- /dev/null +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -0,0 +1,308 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' +import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + agentSessionStorePath, + loadAgentSessionStore, + saveAgentSessionStore +} from './agent-session-record-store-file' + +/** Reserve, observe the spawn, prove the handle: the only path to a `live` lease, whose invariant + * is that the head handle was minted at exactly the current fence. One store instance throughout, + * because reopening marks every lease unreconciled and refuses the next two steps. */ +async function seedLiveSession(sessionId: string): Promise { + const store = await openStore() + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'seed-live', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed-live' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: 'seed-live' + }, + now: NOW + }) + await store.proveOwner({ + sessionId, + fence, + link: { + linkId: 'link-live-1', + origin: 'created', + mintedAtFence: fence, + observedAt: NOW, + handle: { provider: 'codex', threadId: `thread-${sessionId}` } + }, + now: NOW + }) + return fence +} + +let root: string +let storePath: string + +const NOW = 1_800_000_000_000 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-')) + storePath = agentSessionStorePath(root) + operations = 0 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function openStore(): Promise { + return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) +} + +let operations = 0 + +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +async function seedSession(sessionId: string): Promise { + const store = await openStore() + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'seed', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed' }, + now: NOW + }) + return reserved.record.lease.runtimeFence +} + +describe('crash-safe store writes', () => { + it('never leaves the live path absent, and keeps a whole backup', async () => { + await seedSession('session-a') + const afterFirst = await readFile(storePath, 'utf-8') + expect(JSON.parse(afterFirst)).toBeTruthy() + + await seedSession('session-b') + // Both candidates parse: the previous generation was COPIED aside, not moved. + expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() + expect(JSON.parse(await readFile(`${storePath}.bak`, 'utf-8'))).toBeTruthy() + }) + + it('leaves no orphaned temp file behind', async () => { + await seedSession('session-a') + await seedSession('session-b') + const { readdir } = await import('node:fs/promises') + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('aborts the save rather than advancing the primary past a stale backup', async () => { + await seedSession('session-a') + const committed = await readFile(storePath, 'utf-8') + const loaded = await loadAgentSessionStore(storePath, 'local') + // A directory in the backup's place makes the rotation fail the way a full or read-only + // disk would. The save must not publish a primary the backup can no longer match. + await rm(`${storePath}.bak`, { force: true }) + const { mkdir } = await import('node:fs/promises') + await mkdir(`${storePath}.bak`) + + await expect( + saveAgentSessionStore(storePath, loaded.state, { + primaryStatus: 'validated' + }) + ).rejects.toBeTruthy() + expect(await readFile(storePath, 'utf-8')).toBe(committed) + expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true) + }) +}) + +describe('recovery from the committed backup', () => { + it('completes the next transaction instead of refusing forever', async () => { + await seedSession('session-a') + await seedSession('session-b') + // The exact shape a real profile wedged in: backup only, no live file. + await rm(storePath, { force: true }) + + await expect(seedSession('session-c')).resolves.toBeGreaterThan(0) + expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() + }) + + it('never grants the fence the lost commit could already have handed out', async () => { + const fence = await seedSession('session-a') + // A second commit is what produces the backup; the first write has nothing to rotate. + await seedSession('session-b') + await rm(storePath, { force: true }) + + const store = await openStore() + // The floor lands in the first transaction after recovery, not at load. + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const record = store.getRecord('session-a') + expect(record).toBeTruthy() + + // The lost commit could have granted fence + 1. Adjudication must skip it, or two writers + // end up holding the same number under strict-equality comparison. + const granted = evaluateAgentSessionAcquisition({ + lease: { ...record!.lease, unreconciled: false, ownerProcess: null, claimStatus: 'released' }, + expectedFence: record!.lease.runtimeFence, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + expect(granted.decision).toBe('granted') + expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) + }) + + it('leaves recovered records valid, so the next load does not quarantine them', async () => { + await seedLiveSession('session-a') + await seedSession('session-b') + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const record = store.getRecord('session-a') + expect(record?.lease.claimStatus).toBe('live') + // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that + // rewrote the fence broke that, so the record failed validation, was quarantined on the next + // load, and dropped straight back to the same backup. + expect(isAgentSessionRecord(record)).toBe(true) + }) + + it('carries ownership evidence forward verbatim', async () => { + await seedSession('session-a') + await seedSession('session-b') + const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( + 'session-a' + )! + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const after = store.getRecord('session-a')! + expect(after.lease.claimStatus).toBe(before.lease.claimStatus) + expect(after.lease.ownerProcess).toEqual(before.lease.ownerProcess) + expect(after.lease.handoffStage).toBe(before.lease.handoffStage) + // "Not currently owned" is expressed by unreconciled, not by erasing the evidence. + expect(after.lease.unreconciled).toBe(true) + }) + + // Recovery restores the previous COMMITTED generation; the lost commit is lost by definition. + // What must not happen is reconciliation dropping anything the backup did hold. + it('drops nothing the committed backup held', async () => { + await seedSession('session-a') + await seedSession('session-b') + const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const after = (await loadAgentSessionStore(storePath, 'local')).state + expect([...after.records.keys()].sort()).toEqual([...before.records.keys()].sort()) + expect(after.operations.size).toBe(before.operations.size) + // Everything the backup held is still there; the transaction that drove recovery adds its own. + for (const key of before.retiredClaimKeys) { + expect(after.retiredClaimKeys).toContainEqual(key) + } + expect([...after.unreadableRecords.keys()]).toEqual([...before.unreadableRecords.keys()]) + }) +}) + +describe('a transient primary read failure is not recovery', () => { + it('refuses rather than falling back to a usable but older backup', async () => { + await seedSession('session-a') + // The second commit leaves a VALID backup, so a fallback would silently succeed with + // older state — the failure this guard exists to prevent. + await seedSession('session-b') + expect( + (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.has('session-a') + ).toBe(true) + + // A directory at the primary path fails the read with EISDIR, not ENOENT — the shape of a + // permission or IO fault. It says nothing about the primary's contents. + await rm(storePath, { force: true }) + const { mkdir } = await import('node:fs/promises') + await mkdir(storePath) + + await expect(loadAgentSessionStore(storePath, 'local')).rejects.toThrow( + 'agent_session_store_corrupt' + ) + }) +}) + +describe('the fence-step bound the +2 floor rests on', () => { + it('advances a session fence by exactly one per grant when no floor is set', () => { + const granted = evaluateAgentSessionAcquisition({ + lease: agentSessionLeaseFixture({ + runtimeFence: 7, + claimStatus: 'released', + ownerProcess: null, + provenHandleLinkId: null + }), + expectedFence: 7, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + // If a grant could ever advance by more than one, the +2 recovery floor would stop dominating + // the highest fence a single lost commit can have handed out. + expect(granted.decision === 'granted' && granted.nextFence).toBe(8) + }) + + it('honours a recovery floor that sits above the next step', () => { + const granted = evaluateAgentSessionAcquisition({ + lease: agentSessionLeaseFixture({ + runtimeFence: 7, + minimumNextFence: 9, + claimStatus: 'released', + ownerProcess: null, + provenHandleLinkId: null + }), + expectedFence: 7, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + expect(granted.decision === 'granted' && granted.nextFence).toBe(9) + }) +}) + +describe('a store that never existed', () => { + it('does not claim recovery', async () => { + await writeFile(join(root, 'unrelated.txt'), 'x', 'utf-8') + const loaded = await loadAgentSessionStore(storePath, 'local') + expect(loaded.storeFound).toBe(false) + expect(loaded.recoveredFromBackup).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts new file mode 100644 index 00000000000..f9fe77cbdc5 --- /dev/null +++ b/src/main/runtime/agent-session-claim-key-state.ts @@ -0,0 +1,46 @@ +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export function isVerifiable( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): boolean { + const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= retentionMs +} + +export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { + return { + ...record, + updatedAt: now, + // A conflicted key must remain conflicted after its observing process exits. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + } +} + +export function retire( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): void { + if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + state.retiredClaimKeys = state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= retentionMs + ) +} + +export function listOrphanSpawnTokens( + records: readonly AgentSessionRecord[], + observedTokens: readonly string[] +): string[] { + const leases = records.map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) +} diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts new file mode 100644 index 00000000000..5bd232c3041 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { recoverDeadTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions' +import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' + +describe('agent session handoff restart transitions', () => { + it('turns a proven dead TUI owner into one durable retry owner', () => { + const operationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'tui', runtimeFence: 3 }) + ) + + const next = recoverDeadTuiOwnerForHandoff({ + record, + expectedFence: 3, + operationId, + probe: { outcome: 'pid-absent' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'tui', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId: operationId, + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'pid-absent' } + }) + }) + + it('preserves the stopped owner and operation for durable retry', () => { + const handoffOperationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + unreconciled: true + }) + ) + + const next = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'reservation-unused' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + unreconciled: false + }) + }) +}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts new file mode 100644 index 00000000000..894d7643abc --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -0,0 +1,187 @@ +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { + assertFence, + evictAgentSessionOwner, + reserveAgentSessionOwner, + withLease +} from './agent-session-lease-transitions' + +export function recoverDeadTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.runtimeKind !== 'tui' || + record.lease.handoffStage !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + const evicted = evictAgentSessionOwner(args) + return withLease(evicted, { + ...evicted.lease, + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId + }) +} + +export function stopAgentSessionOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'observed process exit', + observedAt: args.now + } + }) +} + +export function rollbackAgentSessionHandoffPreparation(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: args.now + }) +} + +export function stopRecoveringTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + (record.lease.handoffStage !== 'recovering' && + record.lease.handoffStage !== 'manual-recovery') || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffOperationId !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'recovery proved TUI process exit', + observedAt: args.now + } + }) +} + +export function reserveAgentSessionHandoffOwner(args: { + record: AgentSessionRecord + expectedFence: number + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs: number +}): AgentSessionRecord { + return reserveAgentSessionOwner({ + record: args.record, + expectedFence: args.expectedFence, + probe: { outcome: 'reservation-unused' }, + reservation: { + runtimeKind: args.runtimeKind, + spawnToken: args.spawnToken, + claimKeyId: args.claimKeyId, + handoffOperationId: args.operationId, + leaseTtlMs: args.leaseTtlMs, + now: args.now + } + }).record +} + +export function abandonAgentSessionHandoffAttempt(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + recoverableRuntimeKind: AgentSessionOwnerRuntimeKind + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeKind: args.recoverableRuntimeKind, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'handoff launch attempt stopped', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts new file mode 100644 index 00000000000..dd8e25531b7 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -0,0 +1,109 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandoffStage } from '../../shared/agent-session-record' +import { + abandonAgentSessionHandoffAttempt, + recoverDeadTuiOwnerForHandoff, + reserveAgentSessionHandoffOwner, + rollbackAgentSessionHandoffPreparation, + stopAgentSessionOwnerForHandoff, + stopRecoveringTuiOwnerForHandoff +} from './agent-session-handoff-lease-transitions' +import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from './agent-session-record-store' + +export function setStoredAgentSessionHandoffStage( + store: AgentSessionRecordStore, + args: { + sessionId: string + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + setAgentSessionHandoffStage({ ...args, record }) + ) +} + +export function recoverStoredDeadTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + probe: AgentSessionOwnerProbe + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + recoverDeadTuiOwnerForHandoff({ ...args, record }) + ) +} + +export function stopStoredAgentSessionOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopAgentSessionOwnerForHandoff({ ...args, record }) + ) +} + +export function rollbackStoredAgentSessionHandoffPreparation( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + rollbackAgentSessionHandoffPreparation({ ...args, record }) + ) +} + +export function stopStoredRecoveringTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopRecoveringTuiOwnerForHandoff({ ...args, record }) + ) +} + +export function reserveStoredAgentSessionHandoffOwner( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + runtimeKind: 'native' | 'tui' + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs?: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + reserveAgentSessionHandoffOwner({ + ...args, + record, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + ) +} + +export function abandonStoredAgentSessionHandoffAttempt( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + recoverableRuntimeKind: 'native' | 'tui' + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + abandonAgentSessionHandoffAttempt({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts new file mode 100644 index 00000000000..97b0f95f2b6 --- /dev/null +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-launch-env' +let directory: string + +function request(overrides: Partial = {}): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW, + ...overrides + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('agent session launch environment admission', () => { + it('does not persist ambient launch variables', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reserveOwner(request()) + await store.reserveOwner( + request({ + expectedFence: 1, + spawnToken: 'spawn-b', + launchEnv: { + PATH: '/custom/bin:/usr/bin', + OPENAI_API_KEY: 'fixture-token' + }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000002`, + fingerprint: 'fp-2' + } + }) + ) + + const raw = await readFile(join(directory, 'agent-sessions.json'), 'utf-8') + expect(raw).not.toContain('OPENAI_API_KEY') + expect(raw).not.toContain('"PATH"') + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reopened.getRecord(SESSION)).not.toHaveProperty('launchEnv') + }) + + it('rejects an environment that could not be validated before writing', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const launchEnv = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) + ) + + await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( + 'agent_session_launch_env_invalid' + ) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('rejects an overlong environment key before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + await expect( + store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) + ).rejects.toThrow('agent_session_launch_env_invalid') + expect(store.getRecord(SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts new file mode 100644 index 00000000000..0c1c88db003 --- /dev/null +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +const NOW = 1_800_000_000_000 +const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const +const directories: string[] = [] + +async function establishOwner( + store: AgentSessionRecordStore, + directory: string, + suffix: string +): Promise { + const sessionId = `session-${suffix}` + const spawnToken = `spawn-${suffix}` + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: directory }, + runtimeKind: 'native', + expectedFence: null, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'renewal-test', + operationId: `${NOW}-${suffix.padStart(32, '0')}`, + fingerprint: `create-${suffix}` + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: { + linkId: `link-${suffix}`, + handle: { provider: 'codex', threadId: `thread-${suffix}` }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function liveStore(): Promise<{ directory: string; store: AgentSessionRecordStore }> { + const directory = await mkdtemp(join(tmpdir(), 'orca-lease-renewal-batch-')) + directories.push(directory) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await establishOwner(store, directory, 'a') + await establishOwner(store, directory, 'b') + return { directory, store } +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('agent-session lease renewal batch', () => { + it('refuses a stale fence without changing the record', async () => { + const { store } = await liveStore() + + await expect( + store.renewLeases([ + { sessionId: 'session-a', fence: 0, childProbe: MATCHED, now: NOW + 10_000 } + ]) + ).rejects.toThrow('agent_session_checkpoint_stale') + expect(store.getRecord('session-a')?.lease.lastRenewedAt).toBe(NOW) + }) + + it('leaves every session durable when a later renewal was superseded', async () => { + const { directory, store } = await liveStore() + await store.evictProvenDeadOwner({ + sessionId: 'session-b', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: NOW + 5_000 + }) + const beforeDisk = await readFile(agentSessionStorePath(directory), 'utf-8') + const beforeFirst = store.getRecord('session-a') + + await expect( + store.renewLeases([ + { sessionId: 'session-a', fence: 1, childProbe: MATCHED, now: NOW + 10_000 }, + { sessionId: 'session-b', fence: 1, childProbe: MATCHED, now: NOW + 10_000 } + ]) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(store.getRecord('session-a')).toEqual(beforeFirst) + expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(beforeDisk) + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect( + reopened + .listRecords() + .map((record) => record.sessionId) + .sort() + ).toEqual(['session-a', 'session-b']) + expect(reopened.listRecords().every((record) => record.providerHandleChain.length > 0)).toBe( + true + ) + }) +}) diff --git a/src/main/runtime/agent-session-lease-renewal.ts b/src/main/runtime/agent-session-lease-renewal.ts new file mode 100644 index 00000000000..1a1f90dd7e1 --- /dev/null +++ b/src/main/runtime/agent-session-lease-renewal.ts @@ -0,0 +1,38 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { renewAgentSessionLease } from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionLeaseRenewal = { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + leaseTtlMs?: number +} + +export function renewAgentSessionLeases( + state: AgentSessionStoreState, + renewals: readonly AgentSessionLeaseRenewal[], + defaultLeaseTtlMs: number +): AgentSessionRecord[] { + return renewals.map((args) => { + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error( + state.unreadableRecords.has(args.sessionId) + ? 'execution_owner_reconciling' + : 'agent_session_identity_required' + ) + } + const renewed = renewAgentSessionLease({ + record, + fence: args.fence, + childProbe: args.childProbe, + now: args.now, + leaseTtlMs: args.leaseTtlMs ?? defaultLeaseTtlMs + }) + state.records.set(args.sessionId, renewed) + return renewed + }) +} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts new file mode 100644 index 00000000000..97fc48f139b --- /dev/null +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -0,0 +1,289 @@ +/** + * Pure lease state transitions. Every function returns the next record or throws a typed error; + * the store applies them inside one durable transaction so a rejected transition never lands. + * + * The invariant they exist to enforce: a session admits a writer only after a reservation, an + * observed process identity, and a proved provider handle — in that order, at one fence. + */ + +import { + adjudicateAgentSessionRestart, + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { + AgentSessionJournalCheckpoint, + AgentSessionHandoffStage, + AgentSessionLease, + AgentSessionOwnerRuntimeKind, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' + +export type AgentSessionReservation = { + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + claimKeyId: string + handoffOperationId: string | null + leaseTtlMs: number + now: number +} + +export function withLease( + record: AgentSessionRecord, + lease: AgentSessionLease +): AgentSessionRecord { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} + +export function assertFence(lease: AgentSessionLease, fence: number): void { + if (lease.runtimeFence !== fence) { + throw new Error('agent_session_checkpoint_stale') + } + if (lease.unreconciled) { + throw new Error('execution_owner_reconciling') + } +} + +/** + * Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the + * loser of a concurrent swap is refused and never spawns. + */ +export function reserveAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe + reservation: AgentSessionReservation +}): { record: AgentSessionRecord; disposition: 'reserved' | 'retry-reservation' } { + const { record, reservation } = args + const decision = evaluateAgentSessionAcquisition({ + lease: record.lease, + expectedFence: args.expectedFence, + handoffOperationId: reservation.handoffOperationId, + probe: args.probe + }) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision === 'retry-reservation') { + return { record, disposition: 'retry-reservation' } + } + return { + disposition: 'reserved', + record: withLease(record, { + ...record.lease, + runtimeKind: reservation.runtimeKind, + runtimeFence: decision.nextFence, + // Why: a reserved owner is not yet a writer; it may only talk to the provider to prove resume. + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: reservation.spawnToken, + processlessAt: null, + leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, + lastRenewedAt: reservation.now, + handoffOperationId: reservation.handoffOperationId, + claimKeyId: reservation.claimKeyId, + claimStatus: 'reserved', + deathEvidence: null + }) + } +} + +/** Step 4 of acquisition: write the observed identity back into the same lease row. */ +export type AgentSessionProcessIdentityCommit = { + sessionId: string + fence: number + process: AgentSessionProcessIdentity + now: number +} + +export function commitAgentSessionProcessIdentity( + args: AgentSessionProcessIdentityCommit & { record: AgentSessionRecord } +): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { + throw new Error('agent_session_ownership_unknown') + } + if (record.lease.reservedSpawnToken !== args.process.spawnToken) { + // Why: a child that cannot echo the reserved token is not the process Orca started. + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + ownerProcess: args.process, + processlessAt: null, + lastRenewedAt: args.now + }) +} + +/** + * The new runtime proved it resumed the expected provider handle. Only now does the session have + * a writer. + */ +export function proveAgentSessionOwner(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number + leaseTtlMs: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + if (args.link.handle.provider !== record.provider) { + throw new Error('agent_session_provider_handle_provider_mismatch') + } + if (args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_stale_fence') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + handoffStage: null, + provenHandleLinkId: head.linkId, + claimStatus: 'live', + leaseDeadlineAt: args.now + args.leaseTtlMs, + lastRenewedAt: args.now, + handoffOperationId: null + }, + updatedAt: args.now + } +} + +/** + * A renewal asserts two things at once: the host is running its loop, and the child still matches + * the recorded identity. A host that cannot re-verify the child stops renewing rather than + * extending a lease it can no longer vouch for. + */ +export function renewAgentSessionLease(args: { + record: AgentSessionRecord + fence: number + childProbe: AgentSessionOwnerProbe + now: number + leaseTtlMs: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if (record.lease.ownerProcess === null) { + throw new Error('agent_session_ownership_unknown') + } + if (args.childProbe.outcome !== 'identity-matched' || args.childProbe.matchedOn.length === 0) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + leaseDeadlineAt: args.now + args.leaseTtlMs, + lastRenewedAt: args.now + }) +} + +/** Proven eviction — the only other thing besides acquisition that may move the fence. */ +export function evictAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe: args.probe, + observedAt: args.now + }) + if (adjudication.disposition === 'free') { + // Nothing outstanding to evict; clearing the latched stage IS the resolution, and no new + // generation was granted, so the fence and the recorded evidence both stay put. + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + lastRenewedAt: args.now + }) + } + if (adjudication.disposition !== 'evicted') { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: adjudication.evidence + }) +} + +export function setAgentSessionHandoffStage(args: { + record: AgentSessionRecord + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if ( + record.lease.handoffOperationId !== null && + args.handoffOperationId !== null && + args.handoffOperationId !== record.lease.handoffOperationId + ) { + throw new Error('agent_session_operation_conflict') + } + return withLease(record, { + ...record.lease, + handoffStage: args.stage, + handoffOperationId: args.handoffOperationId, + lastRenewedAt: args.now + }) +} + +export function setAgentSessionJournalCheckpoint(args: { + record: AgentSessionRecord + fence: number + checkpoint: AgentSessionJournalCheckpoint + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + const current = record.lease.journalCheckpoint + if ( + current && + (current.epoch > args.checkpoint.epoch || + (current.epoch === args.checkpoint.epoch && current.sequence > args.checkpoint.sequence)) + ) { + throw new Error('agent_session_checkpoint_stale') + } + return withLease(record, { + ...record.lease, + journalCheckpoint: args.checkpoint, + lastRenewedAt: args.now + }) +} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts new file mode 100644 index 00000000000..520e75a03c3 --- /dev/null +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -0,0 +1,33 @@ +// Ledger admission for mutations that are not reservations — send, cancel, an +// approval answer. Split from the store so the store keeps only the transaction. + +import { + agentSessionOperationKey, + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' + +export type AgentSessionOperationAdmission = { + callerKey: string + operationId: string + fingerprint: string + now: number +} + +type OperationRows = Map + +/** Prune, evaluate, and (on admit) place the row. The caller runs this inside one + * transaction, so two concurrent copies of an operation id cannot both admit. */ +export function admitAgentSessionOperationRow( + rows: OperationRows, + args: AgentSessionOperationAdmission +): { rows: OperationRows; decision: AgentSessionOperationDecision } { + const pruned = pruneAgentSessionOperationRows(rows, args.now) + const decision = evaluateAgentSessionOperation({ rows: pruned, ...args }) + if (decision.decision === 'admit') { + pruned.set(agentSessionOperationKey(args.callerKey, args.operationId), decision.row) + } + return { rows: pruned, decision } +} diff --git a/src/main/runtime/agent-session-orphan-child-reaper.test.ts b/src/main/runtime/agent-session-orphan-child-reaper.test.ts new file mode 100644 index 00000000000..eb837ac4a22 --- /dev/null +++ b/src/main/runtime/agent-session-orphan-child-reaper.test.ts @@ -0,0 +1,68 @@ +// A child spawned under a reservation whose record was lost is invisible to the lease. Reaping +// is bounded cleanup only; it never replaces the lease proof required to grant another writer. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' + +function storeWithLeasedTokens(tokens: readonly string[]) { + return { + listOrphanSpawnTokens: (observed: readonly string[]) => + observed.filter((token) => !tokens.includes(token)) + } as Pick +} + +describe('orphan agent-session child reaper', () => { + it('stops every process whose spawn token no lease claims', async () => { + const stop = vi.fn() + + const stopped = await stopOrphanAgentSessionChildren({ + store: storeWithLeasedTokens(['token-owned']), + scan: async () => + new Map([ + ['token-owned', [101]], + ['token-lost', [202, 203]] + ]), + stop + }) + + expect(stopped).toEqual([202, 203]) + expect(stop).toHaveBeenCalledWith(202, 'SIGTERM') + expect(stop).toHaveBeenCalledWith(203, 'SIGTERM') + expect(stop).not.toHaveBeenCalledWith(101, 'SIGTERM') + }) + + it('stops nothing on a host that cannot enumerate spawn tokens', async () => { + const stop = vi.fn() + + // Null is "cannot answer", never "no tokens" — treating it as an empty scan would be a + // license to signal nothing, but a future empty-map reading would be a license to signal + // whatever the caller guessed. + const stopped = await stopOrphanAgentSessionChildren({ + store: { + listOrphanSpawnTokens: () => { + throw new Error('the reaper must not ask when the host could not answer') + } + }, + scan: async () => null, + stop + }) + + expect(stopped).toEqual([]) + expect(stop).not.toHaveBeenCalled() + }) + + it('surfaces a failure to signal an observed orphan', async () => { + const failure = Object.assign(new Error('not permitted'), { code: 'EPERM' }) + + await expect( + stopOrphanAgentSessionChildren({ + store: storeWithLeasedTokens([]), + scan: async () => new Map([['token-lost', [202]]]), + stop: () => { + throw failure + } + }) + ).rejects.toBe(failure) + }) +}) diff --git a/src/main/runtime/agent-session-orphan-child-reaper.ts b/src/main/runtime/agent-session-orphan-child-reaper.ts new file mode 100644 index 00000000000..68d9c899024 --- /dev/null +++ b/src/main/runtime/agent-session-orphan-child-reaper.ts @@ -0,0 +1,48 @@ +/** + * Best-effort stop for provider children that carry an Orca spawn token no lease claims. + * + * A child spawned under a reservation whose record was lost — the primary store file went with it, + * or the crash beat the durable write — is unreachable but still connected to the provider session. + * Only a token match justifies signalling a process; neither age nor CPU is evidence, and a host + * that cannot enumerate tokens stops nothing. This never proves process exit or licenses a new + * owner; lease adjudication remains the single-writer boundary. + */ + +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + scanAgentSessionSpawnTokenProcesses, + type AgentSessionSpawnTokenScan +} from './agent-session-spawn-token-process-scan' + +export type AgentSessionOrphanStopSignal = 'SIGTERM' | 'SIGKILL' + +function defaultStop(pid: number, signal: AgentSessionOrphanStopSignal): void { + try { + process.kill(pid, signal) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + } +} + +/** Returns the pids signalled, so the caller can report what it reaped. */ +export async function stopOrphanAgentSessionChildren(input: { + store: Pick + scan?: () => Promise + stop?: (pid: number, signal: AgentSessionOrphanStopSignal) => void +}): Promise { + const observed = await (input.scan ?? scanAgentSessionSpawnTokenProcesses)() + if (observed === null || observed.size === 0) { + return [] + } + const stop = input.stop ?? defaultStop + const stopped: number[] = [] + for (const token of input.store.listOrphanSpawnTokens([...observed.keys()])) { + for (const pid of observed.get(token) ?? []) { + stop(pid, 'SIGTERM') + stopped.push(pid) + } + } + return stopped +} diff --git a/src/main/runtime/agent-session-process-identity-probe.test.ts b/src/main/runtime/agent-session-process-identity-probe.test.ts new file mode 100644 index 00000000000..17737de05d7 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { + PROCESS_START_TIME_TOLERANCE_MS, + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation, + readProcessStartTimeMs, + type AgentSessionProcessProbeDeps +} from './agent-session-process-identity-probe' + +const START_TIME = 1_700_000_000_000 + +const IDENTITY: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 4242, + processStartTimeMs: START_TIME, + spawnToken: 'spawn-a' +} + +function deps(overrides: AgentSessionProcessProbeDeps = {}): AgentSessionProcessProbeDeps { + return { + isPidPresent: () => true, + readProcessStartTimeMs: async () => START_TIME, + readEchoedSpawnToken: async () => 'spawn-a', + platform: 'linux', + ...overrides + } +} + +describe('owner identity probe', () => { + it('shares one process-table read across a batch without weakening identity checks', async () => { + const readProcessStartTimes = vi.fn( + async () => + new Map([ + [4242, START_TIME], + [4243, START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1] + ]) + ) + const probes = await probeAgentSessionProcessIdentities({ + identities: [IDENTITY, { ...IDENTITY, pid: 4243, spawnToken: 'spawn-b' }], + deps: { + isPidPresent: () => true, + readEchoedSpawnToken: async () => null, + readProcessStartTimesMs: readProcessStartTimes, + platform: 'darwin' + } + }) + + expect(readProcessStartTimes).toHaveBeenCalledOnce() + expect(readProcessStartTimes).toHaveBeenCalledWith([4242, 4243], 'darwin') + expect(probes).toEqual([ + { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, + { outcome: 'identity-mismatch', field: 'process-start-time' } + ]) + }) + + it('reports an observed exit without touching the host', () => { + return expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + observedExit: true, + deps: deps({ + isPidPresent: () => { + throw new Error('must not probe after an observed exit') + } + }) + }) + ).resolves.toEqual({ outcome: 'exit-observed' }) + }) + + it('reports an absent pid as proven death', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ isPidPresent: () => false }) + }) + ).resolves.toEqual({ outcome: 'pid-absent' }) + }) + + it('does not call an unexpected host probe error proof of death', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('host probe unavailable'), { code: 'EIO' }) + }) + try { + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: { readEchoedSpawnToken: async () => null } + }) + expect(probe.outcome).toBe('indeterminate') + } finally { + kill.mockRestore() + } + }) + + it('catches pid reuse through the spawn token', async () => { + // The pid is live and started at the recorded time, but it is a different process. + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ readEchoedSpawnToken: async () => 'spawn-other' }) + }) + ).resolves.toEqual({ outcome: 'identity-mismatch', field: 'spawn-token' }) + }) + + it('catches pid reuse through the start time when no token comes back', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readEchoedSpawnToken: async () => null, + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toEqual({ outcome: 'identity-mismatch', field: 'process-start-time' }) + }) + + it('fails closed when an exact token and the reconstructed start time disagree', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toEqual({ + outcome: 'indeterminate', + reason: 'process identity evidence contradicted' + }) + }) + + it('tolerates start-time jitter inside the tolerance', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readEchoedSpawnToken: async () => null, + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + }) + }) + ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }) + }) + + it('reports every element it could actually verify', async () => { + await expect( + probeAgentSessionProcessIdentity({ identity: IDENTITY, deps: deps() }) + ).resolves.toEqual({ + outcome: 'identity-matched', + matchedOn: ['spawn-token', 'process-start-time'] + }) + }) + + it('fails closed when the pid is live but nothing PID-reuse-safe could be checked', async () => { + // The Windows case: no start time recorded and no token echo, so a bare pid match is all the + // host has — and a bare pid match is what mints a second writer. + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: deps({ readEchoedSpawnToken: async () => null, platform: 'win32' }) + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('fails closed when the host errors instead of answering', async () => { + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: deps({ + readEchoedSpawnToken: async () => { + throw new Error('handshake unavailable') + } + }) + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('still proves life from the token when the start time is unreadable', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ readProcessStartTimeMs: async () => null }) + }) + ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + }) + + it('reads a process-table start time on Windows when running there', async () => { + const observed = await readProcessStartTimeMs(process.pid, 'win32') + expect(observed === null).toBe(process.platform !== 'win32') + }) + + it('reads a start time for the current process on this platform', async () => { + const observed = await readProcessStartTimeMs(process.pid) + if ( + process.platform === 'linux' || + process.platform === 'darwin' || + process.platform === 'win32' + ) { + expect(observed).not.toBeNull() + expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan( + 60_000 + ) + } else { + expect(observed).toBeNull() + } + }) +}) + +describe('reservation probe', () => { + it('declares a reservation unused only with positive proof nothing started', async () => { + await expect( + probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: async () => [], + hasProviderActivitySinceReservation: async () => false + }) + ).resolves.toEqual({ outcome: 'reservation-unused' }) + }) + + it.each([ + ['a process still carries the token', async () => [999], async () => false], + ['the host cannot enumerate', async () => null, async () => false], + ['provider activity is unknown', async () => [], async () => null], + ['the provider saw activity', async () => [], async () => true] + ] as const)('stays indeterminate when %s', async (_name, findProcesses, hasActivity) => { + const probe = await probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: findProcesses, + hasProviderActivitySinceReservation: hasActivity + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('stays indeterminate when enumeration throws', async () => { + const probe = await probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: async () => { + throw new Error('ps unavailable') + }, + hasProviderActivitySinceReservation: async () => false + }) + expect(probe.outcome).toBe('indeterminate') + }) +}) diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts new file mode 100644 index 00000000000..51577048d31 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -0,0 +1,273 @@ +/** + * PID-reuse-safe process identity probe for the single-writer lease. + * + * Pids are reused within minutes on a busy host, and reuse happens precisely in the recovery + * case, so a bare pid match is never proof. Every element of the identity tuple is unavailable + * somewhere — start time costs a CIM query on Windows and is missing in some containers, /proc + * does not exist on macOS — so an exact but unanswerable identity stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ + +import { readFile } from 'node:fs/promises' +import type { + AgentSessionIdentityMatchField, + AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { runProcess } from '../../shared/child-process/run-process' +import { readWindowsProcessTableFresh } from '../windows/windows-process-table' + +/** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */ +export const PROCESS_START_TIME_TOLERANCE_MS = 2_000 + +const PROCESS_START_TIME_TIMEOUT_MS = 5_000 + +export type AgentSessionProcessProbeDeps = { + /** ESRCH means gone; EPERM means present but owned by another user. */ + isPidPresent?: (pid: number) => boolean + readProcessStartTimeMs?: (pid: number, platform?: NodeJS.Platform) => Promise + /** Token the running child echoed back through the adapter handshake or provider hook. */ + readEchoedSpawnToken?: (identity: AgentSessionProcessIdentity) => Promise + platform?: NodeJS.Platform +} + +function defaultIsPidPresent(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // Why: only ESRCH proves absence; permission and transient host failures must fail closed. + return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' + } +} + +async function readLinuxProcessStartTimeMs(pid: number): Promise { + try { + const [stat, systemStat] = await Promise.all([ + readFile(`/proc/${pid}/stat`, 'utf-8'), + readFile('/proc/stat', 'utf-8') + ]) + // Field 22 is starttime in clock ticks; the comm field can contain spaces, so cut past ") ". + const fields = stat.slice(stat.lastIndexOf(') ') + 2).split(' ') + const ticks = Number(fields[19]) + const bootTimeSeconds = Number(/^btime\s+(\d+)$/m.exec(systemStat)?.[1]) + if (!Number.isFinite(ticks) || !Number.isFinite(bootTimeSeconds)) { + return null + } + return Math.round(bootTimeSeconds * 1000 + (ticks / 100) * 1000) + } catch { + return null + } +} + +async function readDarwinProcessStartTimeMs(pid: number): Promise { + try { + const result = await runProcess({ + program: 'ps', + args: ['-o', 'lstart=', '-p', String(pid)], + timeoutMs: PROCESS_START_TIME_TIMEOUT_MS + }) + if (result.timedOut || result.code !== 0) { + return null + } + const parsed = Date.parse(result.stdout.trim()) + return Number.isFinite(parsed) ? parsed : null + } catch { + return null + } +} + +async function readDarwinProcessStartTimesMs( + pids: readonly number[] +): Promise> { + const observed = new Map() + if (pids.length === 0) { + return observed + } + try { + const result = await runProcess({ + program: 'ps', + args: ['-o', 'pid=,lstart=', '-p', pids.join(',')], + timeoutMs: PROCESS_START_TIME_TIMEOUT_MS + }) + if (result.timedOut || result.code !== 0) { + return observed + } + for (const line of result.stdout.split('\n')) { + const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line) + if (!match) { + continue + } + const pid = Number(match[1]) + const parsed = Date.parse(match[2]) + if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) { + observed.set(pid, parsed) + } + } + } catch { + // A missing process table is unknown, never evidence that every owner exited. + } + return observed +} + +async function readWindowsProcessStartTimeMs(pid: number): Promise { + try { + const row = (await readWindowsProcessTableFresh()).find((candidate) => candidate.pid === pid) + return row?.creationTimeMs ?? null + } catch { + return null + } +} + +/** + * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the + * spawn token back to the owner probe. + */ +export async function readProcessStartTimeMs( + pid: number, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform === 'linux') { + return readLinuxProcessStartTimeMs(pid) + } + if (platform === 'darwin') { + return readDarwinProcessStartTimeMs(pid) + } + if (platform === 'win32') { + return readWindowsProcessStartTimeMs(pid) + } + return null +} + +export async function readProcessStartTimesMs( + pids: readonly number[], + platform: NodeJS.Platform = process.platform +): Promise> { + const uniquePids = [...new Set(pids)] + if (platform === 'darwin') { + const table = await readDarwinProcessStartTimesMs(uniquePids) + return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null])) + } + return new Map( + await Promise.all( + uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const) + ) + ) +} + +export type AgentSessionProcessBatchProbeDeps = Omit< + AgentSessionProcessProbeDeps, + 'readProcessStartTimeMs' +> & { + readProcessStartTimesMs?: ( + pids: readonly number[], + platform?: NodeJS.Platform + ) => Promise> +} + +export async function probeAgentSessionProcessIdentities(args: { + identities: readonly AgentSessionProcessIdentity[] + deps?: AgentSessionProcessBatchProbeDeps +}): Promise { + const deps = args.deps ?? {} + const platform = deps.platform ?? process.platform + const pids = args.identities + .filter((identity) => identity.processStartTimeMs !== null) + .map((identity) => identity.pid) + const readStartTimes = deps.readProcessStartTimesMs ?? readProcessStartTimesMs + const startTimes = await readStartTimes(pids, platform).catch(() => new Map()) + return Promise.all( + args.identities.map((identity) => + probeAgentSessionProcessIdentity({ + identity, + deps: { + ...deps, + platform, + readProcessStartTimeMs: async (pid) => startTimes.get(pid) ?? null + } + }) + ) + ) +} + +/** + * Probe one recorded owner. `observedExit` short-circuits everything: Orca watching that exact + * process exit is the strongest evidence available. + */ +export async function probeAgentSessionProcessIdentity(args: { + identity: AgentSessionProcessIdentity + observedExit?: boolean + deps?: AgentSessionProcessProbeDeps +}): Promise { + const { identity } = args + const deps = args.deps ?? {} + if (args.observedExit) { + return { outcome: 'exit-observed' } + } + const isPidPresent = deps.isPidPresent ?? defaultIsPidPresent + if (!isPidPresent(identity.pid)) { + return { outcome: 'pid-absent' } + } + const matchedOn: AgentSessionIdentityMatchField[] = [] + const echoedToken = await deps.readEchoedSpawnToken?.(identity).catch(() => null) + if (echoedToken !== null && echoedToken !== undefined) { + if (echoedToken !== identity.spawnToken) { + return { outcome: 'identity-mismatch', field: 'spawn-token' } + } + matchedOn.push('spawn-token') + } + if (identity.processStartTimeMs !== null) { + const readStartTime = deps.readProcessStartTimeMs ?? readProcessStartTimeMs + const observed = await readStartTime(identity.pid, deps.platform ?? process.platform).catch( + () => null + ) + if (observed !== null) { + if (Math.abs(observed - identity.processStartTimeMs) > PROCESS_START_TIME_TOLERANCE_MS) { + if (matchedOn.includes('spawn-token')) { + // Why: contradictory evidence cannot prove that a token-authenticated child is dead. + return { outcome: 'indeterminate', reason: 'process identity evidence contradicted' } + } + return { outcome: 'identity-mismatch', field: 'process-start-time' } + } + matchedOn.push('process-start-time') + } + } + if (matchedOn.length === 0) { + // Why: the pid exists and nothing PID-reuse-safe could be checked. Reporting a match here is + // exactly the case that produces two writers on one provider session. + return { + outcome: 'indeterminate', + reason: 'pid present but neither spawn token nor start time could be verified' + } + } + return { outcome: 'identity-matched', matchedOn } +} + +/** + * Probe a reservation that has no recorded process. `reservation-unused` requires positive proof + * that nothing started — no process carrying the token and no provider-side activity after the + * reservation — not an assumption that the crash beat the spawn. + */ +export async function probeAgentSessionReservation(args: { + spawnToken: string + findProcessesWithSpawnToken: (spawnToken: string) => Promise + hasProviderActivitySinceReservation: () => Promise +}): Promise { + const pids = await args.findProcessesWithSpawnToken(args.spawnToken).catch(() => null) + if (pids === null) { + return { outcome: 'indeterminate', reason: 'host could not enumerate spawn tokens' } + } + if (pids.length > 0) { + return { + outcome: 'indeterminate', + reason: `reservation spawn token is live on ${pids.length} process(es)` + } + } + const providerActivity = await args.hasProviderActivitySinceReservation().catch(() => null) + if (providerActivity === null) { + return { outcome: 'indeterminate', reason: 'provider activity since reservation is unknown' } + } + return providerActivity + ? { outcome: 'indeterminate', reason: 'provider saw activity after the reservation' } + : { outcome: 'reservation-unused' } +} diff --git a/src/main/runtime/agent-session-processless-reservation.ts b/src/main/runtime/agent-session-processless-reservation.ts new file mode 100644 index 00000000000..9415ba8fcb7 --- /dev/null +++ b/src/main/runtime/agent-session-processless-reservation.ts @@ -0,0 +1,44 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export type AgentSessionReservationProcesslessProof = { + sessionId: string + fence: number + spawnToken: string + now: number +} + +function assertReservation( + record: AgentSessionRecord, + args: AgentSessionReservationProcesslessProof +): void { + if (record.lease.runtimeFence !== args.fence || record.lease.unreconciled) { + throw new Error('agent_session_checkpoint_stale') + } + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.reservedSpawnToken !== args.spawnToken + ) { + throw new Error('agent_session_ownership_unknown') + } +} + +export function setAgentSessionReservationProcesslessProof( + args: AgentSessionReservationProcesslessProof & { + record: AgentSessionRecord + processlessAt: number | null + } +): AgentSessionRecord { + const { record } = args + assertReservation(record, args) + if (args.processlessAt === null && record.lease.processlessAt == null) { + return record + } + if (record.lease.ownerProcess !== null) { + throw new Error('agent_session_ownership_unknown') + } + return { + ...record, + lease: { ...record.lease, processlessAt: args.processlessAt }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts new file mode 100644 index 00000000000..19131562366 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +function resumedLink(fence: number): AgentSessionProviderHandleLink { + return { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-2' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: 4_000 + } +} + +describe('recordAgentSessionProviderHandle', () => { + it('advances a live Claude chain head and its proof', () => { + const record = agentSessionRecordFixture() + const next = recordAgentSessionProviderHandle({ + record, + fence: record.lease.runtimeFence, + link: resumedLink(record.lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease.provenHandleLinkId).toBe('link-2') + }) + + it('records a leaf during proof without granting ownership', () => { + const lease = agentSessionLeaseFixture({ + runtimeFence: 8, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + provenHandleLinkId: null + }) + const next = recordAgentSessionProviderHandle({ + record: agentSessionRecordFixture(lease), + fence: lease.runtimeFence, + link: resumedLink(lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) + }) +}) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts new file mode 100644 index 00000000000..474e4be7135 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -0,0 +1,41 @@ +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function recordAgentSessionProviderHandle(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number +}): AgentSessionRecord { + const { record } = args + if (record.lease.runtimeFence !== args.fence) { + throw new Error('agent_session_stale_fence') + } + if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_invalid') + } + if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { + throw new Error('agent_session_ownership_unknown') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + ...(record.lease.claimStatus === 'live' ? { provenHandleLinkId: head.linkId } : {}), + lastRenewedAt: args.now + }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-pty-write-enforcement.test.ts b/src/main/runtime/agent-session-pty-write-enforcement.test.ts new file mode 100644 index 00000000000..e81275f90ed --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-enforcement.test.ts @@ -0,0 +1,368 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import { AGENT_PROMPT_SUBMIT } from '../../shared/agent-prompt-injection' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +// The runtime send paths are the choke point every RPC, plugin, and orchestration write funnels +// through, so each one is proved to consult the lease and to leave unbound PTYs untouched. + +const WORKTREE_ID = 'repo-1::/tmp/lease-worktree' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const RUN_ID = 'run-1' +const PTY_ID = 'pty-agent-session' +const SESSION_ID = 'session-alpha-1' + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/lease-worktree', + displayName: 'lease', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +const records = new Map() + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +async function makeRuntime(options: { onWrite?: (ptyId: string, data: string) => void } = {}) { + const runtime = new OrcaRuntimeService(makeStore() as never) + const write = vi.fn((ptyId: string, data: string) => { + options.onWrite?.(ptyId, data) + // A real agent starts working when it receives the submit, and the prompt path now waits for + // that transition before it reports success. Without it every happy path here reads as stalled. + if (data === AGENT_PROMPT_SUBMIT) { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now()) + } + return true + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => []), + hasPty: () => true + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + }) + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle, write } +} + +function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { + publish(lease) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) +} + +afterEach(() => { + agentSessionPtyWriteGate.detachRecordLookup() + records.clear() +}) + +describe('exemption: nothing that exists today is enforced', () => { + it('sends normally when no session record is bound to the pty', async () => { + const { runtime, handle, write } = await makeRuntime() + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('sends normally when the store is attached but this pty is a legacy agent terminal', async () => { + const { runtime, handle, write } = await makeRuntime() + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty('some-other-pty', SESSION_ID) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('sends agent prompts normally on an unbound pty', async () => { + const { runtime, handle, write } = await makeRuntime() + + await expect(runtime.sendTerminalAgentPrompt(handle, 'go')).resolves.toBeDefined() + + expect(write).toHaveBeenCalled() + }) +}) + +describe('terminal.send path', () => { + it('writes when the TUI owner holds a proven-live lease', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('refuses and writes nothing when native chat owns the session', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses while a handoff is in flight', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ handoffStage: 'new-owner-proving' })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses while the lease is unreconciled after a host restart', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ unreconciled: true })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'execution_owner_reconciling' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses an interrupt, which carries no text of its own', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminal(handle, { interrupt: true })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses before the mobile floor is reserved', async () => { + const { runtime, handle } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + const reserveWrite = vi.fn() + + await expect(runtime.sendTerminal(handle, { text: 'ls' }, { reserveWrite })).rejects.toThrow() + + expect(reserveWrite).not.toHaveBeenCalled() + }) + + it('refuses when an async send guard outlives the admitted fence', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect( + runtime.sendTerminal( + handle, + { text: 'ls' }, + { + beforeWrite: async () => { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + await Promise.resolve() + } + } + ) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('agent prompt path', () => { + it('refuses the whole paste when another runtime owns the session', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('writes the prompt when the TUI owner still holds the lease', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).resolves.toBeDefined() + + expect(write).toHaveBeenCalled() + }) + + it('does not terminate a partial paste after its admitted fence moved', async () => { + const { runtime, handle, write } = await makeRuntime({ + onWrite: () => publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect( + runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2)) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(write).toHaveBeenCalledTimes(1) + }) +}) + +describe('lease transition against an in-flight write', () => { + const CHUNKED_TEXT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8) + + it('stops a paste mid-flight once the fence advances under it', async () => { + let written = 0 + const { runtime, handle, write } = await makeRuntime({ + onWrite: () => { + written += 1 + if (written === 1) { + // A handoff completed between the first and second chunk. + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + + expect(write).toHaveBeenCalledTimes(1) + }) + + it('lets a paste finish while the same owner holds the fence', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledTimes(3) + }) + + it('fences preview paste chunks to the lease admitted before the first chunk', async () => { + let written = 0 + const { runtime, write } = await makeRuntime({ + onWrite: () => { + written += 1 + if (written === 1) { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.writeTerminalPreviewInput(PTY_ID, CHUNKED_TEXT)).resolves.toBe(false) + + expect(write).toHaveBeenCalledTimes(1) + }) + + it('withholds the submit when the lease moves during the text/suffix pause', async () => { + const { runtime, handle, write } = await makeRuntime({ + onWrite: (_ptyId, data) => { + if (data === 'ls') { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.sendTerminal(handle, { text: 'ls', enter: true })).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + + expect(write).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalledWith(PTY_ID, '\r') + }) + + it('withholds orchestration Enter after the pointer lease fence moves', async () => { + vi.useFakeTimers() + try { + const { runtime, handle, write } = await makeRuntime({ + onWrite: (_ptyId, data) => { + if (data.includes('orca orchestration check')) { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + let messages: { id: string; sequence: number; type: string }[] = [] + // Why run-scoped: pointer delivery only serves `run:` mailboxes, and it stages the + // batch as delivered before writing — a fake missing either makes the fence + // assertion below vacuous because nothing is ever written. + runtime.setOrchestrationDb({ + getUndeliveredUnreadMessages: () => messages, + getCurrentRunForPane: () => ({ id: RUN_ID }), + getRun: () => ({ id: RUN_ID, coordinator_handle: handle }), + markAsDelivered: () => undefined + } as never) + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) + runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + messages = [{ id: 'msg-1', sequence: 1, type: 'status' }] + + runtime.deliverPendingMessagesForHandle(`run:${RUN_ID}`) + expect(write).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(500) + + expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts new file mode 100644 index 00000000000..94ad79c051b --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { isAgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' + +const PTY_ID = 'pty-1' +const SESSION_ID = 'session-alpha-1' + +let gate: AgentSessionPtyWriteGate +let records: Map + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +beforeEach(() => { + gate = new AgentSessionPtyWriteGate() + records = new Map() +}) + +describe('capability invisibility', () => { + it('admits every PTY while nothing is bound, which is the shape of today builds', () => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + expect(gate.enforcing).toBe(false) + expect(gate.admit('any-shell')).toEqual({ + admitted: true, + sessionId: null, + runtimeFence: null + }) + }) + + it('admits an unbound PTY even once another PTY is bound and refusing', () => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admit('ordinary-shell').admitted).toBe(true) + }) + + it('admits a bound PTY while no store is attached, so a half-wired host cannot refuse', () => { + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.enforcing).toBe(false) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) +}) + +describe('binding lifecycle', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + }) + + it('reports the session a PTY is bound to', () => { + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(gate.boundSessionId('other')).toBeNull() + }) + + it('returns an unbound PTY to the exempt path when it is unbound', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.admit(PTY_ID).admitted).toBe(false) + gate.unbindPty(PTY_ID) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) + + it('drops every binding when the store detaches', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + gate.detachRecordLookup() + expect(gate.enforcing).toBe(false) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) +}) + +describe('overlapping adoption attempts on one pane', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + }) + + it('keeps the newer attempt bound when the one it superseded gives up', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-b')).toBe(true) + + // Both attempts carry the same session, so only the spawn token can tell this release apart. + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-b')).toBe(true) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) + + it('leaves a settled owner pane alone when a later attempt fails', () => { + gate.bindPty(PTY_ID, SESSION_ID) + + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-late')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-late')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles a proven attempt so no later attempt can release its pane', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(true) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles nothing once the pane is gone', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + gate.unbindPty(PTY_ID) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) +}) + +describe('admission through the store', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + gate.bindPty(PTY_ID, SESSION_ID) + }) + + it('admits a proven-live TUI owner and reports the fence it was admitted under', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 11 })) + expect(gate.admit(PTY_ID)).toEqual({ + admitted: true, + sessionId: SESSION_ID, + runtimeFence: 11 + }) + }) + + it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses TUI writes while handoff stage %s is active', + (handoffStage) => { + publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage })) + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + if (!admission.admitted) { + expect(admission.refusal).toMatchObject({ + code: 'agent_session_conflict', + handoffStage, + ownerRuntimeKind: 'tui' + }) + } + } + ) + + it('admits only the reserved TUI proof token while the new process is proving', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'proof-token' + }, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + expect( + gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' }) + ).toBe(false) + }) + + it('refuses proof input that does not match the committed process identity', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'other-token' + }, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( + false + ) + }) + + it('refuses proof input after ownership is live', () => { + publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' })) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( + false + ) + }) + + it('admits proof input for the exact proven live owner during restore', () => { + publish( + agentSessionLeaseFixture({ + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'live-proof-token' + }, + reservedSpawnToken: 'live-proof-token' + }) + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'live-proof-token' })).toBe( + true + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + }) + + it('refuses when the record vanished from the store', () => { + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + }) + + it('throws the typed refusal from assertAdmitted', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + let thrown: unknown = null + try { + gate.assertAdmitted(PTY_ID) + } catch (error) { + thrown = error + } + expect(isAgentSessionPtyWriteRefusedError(thrown)).toBe(true) + if (!isAgentSessionPtyWriteRefusedError(thrown)) { + return + } + expect(thrown.refusal.code).toBe('agent_session_conflict') + expect(thrown.refusal.ownerRuntimeKind).toBe('native') + }) + + it('returns the admittance from assertAdmitted so later chunks can be fenced', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + expect(gate.assertAdmitted(PTY_ID)).toEqual({ sessionId: SESSION_ID, runtimeFence: 3 }) + }) + + it('throws from assertReadmitted once the lease moved under an in-flight write', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + const admitted = gate.assertAdmitted(PTY_ID) + publish(agentSessionLeaseFixture({ runtimeFence: 4 })) + expect(() => gate.assertReadmitted(PTY_ID, admitted)).toThrowError( + 'agent_session_checkpoint_stale' + ) + }) + + it('lets an in-flight write finish while the lease is unchanged', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + const admitted = gate.assertAdmitted(PTY_ID) + expect(() => gate.assertReadmitted(PTY_ID, admitted)).not.toThrow() + }) + + it('leaves an in-flight write on an exempt PTY alone', () => { + const admitted = gate.assertAdmitted('ordinary-shell') + expect(admitted).toEqual({ sessionId: null, runtimeFence: null }) + expect(() => gate.assertReadmitted('ordinary-shell', admitted)).not.toThrow() + }) + + it('refuses an exempt write that acquired a refusing binding mid-flight', () => { + const admitted = gate.assertAdmitted('late-bound') + publish(agentSessionLeaseFixture({ sessionId: 'session-beta-2', runtimeKind: 'native' })) + gate.bindPty('late-bound', 'session-beta-2') + expect(() => gate.assertReadmitted('late-bound', admitted)).toThrow() + }) +}) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts new file mode 100644 index 00000000000..8fb078eb3b9 --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -0,0 +1,179 @@ +/** + * Host-side registry that maps a live PTY to the durable agent session it belongs to, and answers + * whether bytes may enter that PTY right now. + * + * It lives on the execution host that owns the process, never in a client: a paired desktop, a + * mobile client, and an SSH-attached Orca all reach the same gate through the host that spawned + * the PTY. With nothing bound — the state of every build until a later part registers records — + * `admit` short-circuits to admitted, so no existing terminal path changes behavior or cost. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + AgentSessionPtyWriteRefusedError, + evaluateAgentSessionPtyWriteAdmission, + reevaluateAgentSessionPtyWriteAdmission, + type AgentSessionPtyBinding, + type AgentSessionPtyWriteAdmission +} from '../../shared/agent-session-pty-write-admission' + +export type AgentSessionRecordLookup = (sessionId: string) => AgentSessionRecord | null + +/** What an admitted write carries forward so its later chunks can be fenced against the same lease. */ +export type AgentSessionPtyWriteAdmittance = { + sessionId: string | null + runtimeFence: number | null +} + +const ADMITTED_UNBOUND: AgentSessionPtyWriteAdmission = { + admitted: true, + sessionId: null, + runtimeFence: null +} + +/** + * A pane binding, plus the adoption attempt that is still proving it. + * + * `attemptToken` is null once an owner is proven — a settled binding no attempt may take away. + * While it is non-null the binding belongs to that one in-flight attempt, which is the only thing + * that tells two overlapping adoptions apart: both carry the same sessionId. + */ +type BoundPane = { sessionId: string; attemptToken: string | null } + +export class AgentSessionPtyWriteGate { + private readonly panesByPtyId = new Map() + private lookup: AgentSessionRecordLookup | null = null + + /** Point the gate at the durable store. Until this is called nothing can be enforced. */ + attachRecordLookup(lookup: AgentSessionRecordLookup): void { + this.lookup = lookup + } + + detachRecordLookup(): void { + this.lookup = null + this.panesByPtyId.clear() + } + + bindPty(ptyId: string, sessionId: string): void { + this.panesByPtyId.set(ptyId, { sessionId, attemptToken: null }) + } + + unbindPty(ptyId: string): void { + this.panesByPtyId.delete(ptyId) + } + + /** + * Claim the pane for one adoption attempt, identified by the spawn token its reservation minted. + * A settled owner keeps the pane; another attempt's claim is superseded, because the newest + * reservation is the one the record now names. Callers must already have refused a pane bound to + * a different session. + */ + bindPtyForAttempt(ptyId: string, sessionId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current && current.attemptToken === null) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId, attemptToken }) + return true + } + + /** Promote this attempt's claim to a settled binding once its owner is proven. */ + settlePtyAttempt(ptyId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId: current.sessionId, attemptToken: null }) + return true + } + + /** + * Compare-and-clear: hand the pane back only while this attempt still holds it. A losing attempt + * that unbinds by pty alone rips the pane out from under the attempt that superseded it, and then + * that one's proof is refused too — both fail where one should have won. + */ + releasePtyAttempt(ptyId: string, attemptToken: string): boolean { + if (this.panesByPtyId.get(ptyId)?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.delete(ptyId) + return true + } + + boundSessionId(ptyId: string): string | null { + return this.panesByPtyId.get(ptyId)?.sessionId ?? null + } + + /** False while no PTY is bound, which is every write path in today's builds. */ + get enforcing(): boolean { + return this.lookup !== null && this.panesByPtyId.size > 0 + } + + admit(ptyId: string): AgentSessionPtyWriteAdmission { + if (!this.enforcing) { + return ADMITTED_UNBOUND + } + return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) + } + + /** Narrow pre-ownership input for the reserved TUI's provider identity probe. */ + admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { + const binding = this.binding(ptyId) + const lease = binding?.record?.lease + const provingReservation = + lease?.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.reservedSpawnToken === authority.spawnToken && + (lease.ownerProcess === null || lease.ownerProcess.spawnToken === authority.spawnToken) + const reprovingLiveOwner = + lease?.claimStatus === 'live' && + lease.handoffStage === null && + lease.ownerProcess?.spawnToken === authority.spawnToken && + lease.provenHandleLinkId !== null + return Boolean( + binding?.sessionId === authority.sessionId && + binding.record?.sessionId === authority.sessionId && + lease?.runtimeKind === 'tui' && + (provingReservation || reprovingLiveOwner) && + !lease.unreconciled + ) + } + + /** Re-check a write already in flight against the fence it was admitted under. */ + readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { + if (admitted.sessionId === null && !this.enforcing) { + return ADMITTED_UNBOUND + } + return reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: this.binding(ptyId) }) + } + + /** Admit or throw the typed refusal. Used where the caller already reports errors to a client. */ + assertAdmitted(ptyId: string): AgentSessionPtyWriteAdmittance { + const admission = this.admit(ptyId) + if (!admission.admitted) { + throw new AgentSessionPtyWriteRefusedError(admission.refusal) + } + return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } + } + + assertReadmitted(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): void { + const admission = this.readmit(ptyId, admitted) + if (!admission.admitted) { + throw new AgentSessionPtyWriteRefusedError(admission.refusal) + } + } + + private binding(ptyId: string): AgentSessionPtyBinding | null { + const pane = this.panesByPtyId.get(ptyId) + if (pane === undefined) { + return null + } + return { sessionId: pane.sessionId, record: this.lookup?.(pane.sessionId) ?? null } + } +} + +/** + * One gate per host process. Both the runtime's send paths and the PTY IPC layer consult this same + * instance, so a write cannot reach a provider by entering through the other door. + */ +export const agentSessionPtyWriteGate = new AgentSessionPtyWriteGate() diff --git a/src/main/runtime/agent-session-reconciliation-target.ts b/src/main/runtime/agent-session-reconciliation-target.ts new file mode 100644 index 00000000000..03fd77be7dd --- /dev/null +++ b/src/main/runtime/agent-session-reconciliation-target.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function agentSessionReconciliationTargetMatches( + current: AgentSessionRecord, + probed: AgentSessionRecord +): boolean { + return isDeepStrictEqual(current, probed) +} diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts new file mode 100644 index 00000000000..a1dfb9ccdef --- /dev/null +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { AgentSessionRecordStore } from './agent-session-record-store' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-options' +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +it('fails option hydration before ownership can be proved', async () => { + await expect( + readNativeSessionOptions({ + adapter: { + readOptions: async () => { + throw new Error('model list unavailable') + } + }, + sessionId: SESSION, + fence: 2 + }) + ).rejects.toThrow('model list unavailable') +}) + +it('persists resumed provider options atomically with owner proof', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-options', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'new session' }, + operation: { + callerKey: 'client-1', + operationId: '1800000000000-00000000000000000000000000000000', + fingerprint: 'options-create' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1, + spawnToken: 'spawn-options' + }, + now: NOW + }) + const options = await readNativeSessionOptions({ + adapter: { + readOptions: async () => ({ + models: [], + current: { model: 'gpt-tui', effort: 'low' } + }) + }, + sessionId: SESSION, + fence + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'codex-options-1', + handle: { provider: 'codex', threadId: 'thread-options' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW, + ...(options ? { options } : {}) + }) + + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) +}) diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts new file mode 100644 index 00000000000..8cf88d685f3 --- /dev/null +++ b/src/main/runtime/agent-session-record-options.ts @@ -0,0 +1,14 @@ +import type { + AgentSessionOptionsReplacement, + AgentSessionRecord +} from '../../shared/agent-session-record' + +export function replaceAgentSessionRecordOptions( + record: AgentSessionRecord, + replacement: AgentSessionOptionsReplacement +): AgentSessionRecord { + if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } +} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts new file mode 100644 index 00000000000..8b8ef6cc4e4 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -0,0 +1,343 @@ +/** + * On-disk layer for the durable agent-session store. + * + * Every mutation is a whole-file atomic transaction — temp write, fsync, rename — so a SIGKILL + * at any point leaves either the previous committed state or the next one, never a torn lease. + * That matters because this host restarts its runtime often; a half-written lease would be + * indistinguishable from an owner whose identity cannot be verified. + */ + +import { createHash } from 'node:crypto' +import { chmod, mkdir, readFile, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + agentSessionOperationKey, + isAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isAgentSessionRecord, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { + copyFileDurable, + durableWriteTempPath, + renameDurable, + writeTempFileDurable +} from '../durable-file-write' + +export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const + +export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' + +export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } + +export type AgentSessionStoreState = { + schemaVersion: number + hostId: string + records: Map + operations: Map + retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map +} + +export type LoadedAgentSessionStore = { + state: AgentSessionStoreState + storeFound: boolean + /** True when the file was written by a newer schema; this host reads but never writes it. */ + readOnly: boolean + /** True when the primary file was unusable and the previous committed copy was used. */ + recoveredFromBackup: boolean + /** True when the normalized current-schema quarantine must be persisted. */ + needsRewrite: boolean +} + +export function agentSessionStorePath(directory: string): string { + return join(directory, AGENT_SESSION_STORE_FILE_NAME) +} + +function backupPath(filePath: string): string { + return `${filePath}.bak` +} + +function emptyState(hostId: string): AgentSessionStoreState { + return { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId, + records: new Map(), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map() + } +} + +export function agentSessionStoreRevision(state: AgentSessionStoreState): string { + return createHash('sha256') + .update(String(state.schemaVersion)) + .update('\0') + .update(serializeState(state)) + .digest('hex') +} + +function parseState( + raw: string, + hostId: string +): { state: AgentSessionStoreState; needsRewrite: boolean } | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const file = parsed as { + schemaVersion?: unknown + hostId?: unknown + records?: unknown + operations?: unknown + retiredClaimKeys?: unknown + unusableRecords?: unknown + } + if ( + !Number.isSafeInteger(file.schemaVersion) || + (file.schemaVersion as number) < 0 || + typeof file.hostId !== 'string' + ) { + return null + } + const schemaVersion = file.schemaVersion as number + if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + if ( + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && + (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) + ) { + return null + } + if ( + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && + (typeof file.operations !== 'object' || + file.operations === null || + Array.isArray(file.operations) || + !Array.isArray(file.retiredClaimKeys) || + typeof file.unusableRecords !== 'object' || + file.unusableRecords === null || + Array.isArray(file.unusableRecords)) + ) { + return null + } + const state = emptyState(hostId) + state.schemaVersion = schemaVersion + state.hostId = file.hostId + let needsRewrite = false + if (typeof file.records === 'object' && file.records !== null) { + for (const [sessionId, value] of Object.entries(file.records)) { + const record = isAgentSessionRecord(value) ? value : null + if (record?.sessionId === sessionId) { + state.records.set(sessionId, record) + } else { + const valueSchemaVersion = + typeof value === 'object' && + value !== null && + (value as { schemaVersion?: unknown }).schemaVersion + const reason = record + ? 'record_key_session_id_mismatch' + : valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ? 'current_shape_invalid' + : 'unsupported_schema' + state.unreadableRecords.set(sessionId, { reason, raw: value }) + needsRewrite ||= schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION + } + } + } + if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { + for (const [sessionId, value] of Object.entries(file.unusableRecords)) { + if (typeof value !== 'object' || value === null) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + const unusable = value as { reason?: unknown; raw?: unknown } + if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) + } + } + if (typeof file.operations === 'object' && file.operations !== null) { + for (const [key, value] of Object.entries(file.operations)) { + if (!isAgentSessionOperationRow(value)) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.operations.set(key, value) + } + } + if (Array.isArray(file.retiredClaimKeys)) { + for (const entry of file.retiredClaimKeys) { + const key = entry as Partial + if ( + typeof key?.keyId !== 'string' || + key.keyId.length === 0 || + key.keyId.length > 512 || + !Number.isSafeInteger(key.retiredAt) || + (key.retiredAt as number) < 0 + ) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) + } + } + return { state, needsRewrite } +} + +/** A record the primary retained as unreadable may still have a valid copy in the previous + * committed state. Adopting it keeps the session reachable — the lease is re-adjudicated + * like any other — while the unreadable bytes stay quarantined verbatim. */ +async function salvageUnreadableRecordsFromBackup( + state: AgentSessionStoreState, + backupFilePath: string, + hostId: string +): Promise { + const missing = [...state.unreadableRecords.keys()].filter( + (sessionId) => !state.records.has(sessionId) + ) + if (missing.length === 0) { + return + } + let raw: string + try { + raw = await readFile(backupFilePath, 'utf-8') + } catch { + return + } + const backup = parseState(raw, hostId) + if (!backup) { + return + } + for (const sessionId of missing) { + const record = backup.state.records.get(sessionId) + if (record) { + state.records.set(sessionId, record) + } + } +} + +export async function loadAgentSessionStore( + filePath: string, + hostId: string +): Promise { + let unusableStoreFound = false + for (const [candidate, recoveredFromBackup] of [ + [filePath, false], + [backupPath(filePath), true] + ] as const) { + let raw: string + try { + raw = await readFile(candidate, 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // Only a missing or unparseable primary means "fall back". A transient read failure + // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as + // recovery would replace newer state with a stale backup and latch the recovery path. + if (!recoveredFromBackup) { + throw new Error('agent_session_store_corrupt') + } + unusableStoreFound = true + } + continue + } + const parsed = parseState(raw, hostId) + if (!parsed) { + unusableStoreFound = true + continue + } + if (!recoveredFromBackup) { + await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) + } + return { + state: parsed.state, + storeFound: true, + readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, + recoveredFromBackup, + needsRewrite: parsed.needsRewrite + } + } + if (unusableStoreFound) { + throw new Error('agent_session_store_corrupt') + } + return { + state: emptyState(hostId), + storeFound: false, + readOnly: false, + recoveredFromBackup: false, + needsRewrite: false + } +} + +function serializeState(state: AgentSessionStoreState): string { + const records: Record = Object.create(null) + for (const [sessionId, record] of state.records) { + records[sessionId] = record + } + return JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: state.hostId, + records, + operations: Object.fromEntries(state.operations), + retiredClaimKeys: state.retiredClaimKeys, + unusableRecords: Object.fromEntries(state.unreadableRecords) + }) +} + +/** + * Commit the whole state. The live path is never absent: the new content is made durable in a temp + * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. + * Backup recovery keeps the known-good backup in place while publishing the repaired primary. + * + * The old ordering renamed the live file aside before writing the new one, so a death in that + * window left the profile with a backup and no primary — which is exactly the state that wedged a + * real profile. Copy, don't move. + */ +export async function saveAgentSessionStore( + filePath: string, + state: AgentSessionStoreState, + options: { primaryStatus: 'validated' | 'unusable-or-absent' } +): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await chmod(directory, 0o700) + const tmpPath = durableWriteTempPath(filePath) + try { + await writeTempFileDurable(tmpPath, serializeState(state), 0o600) + // Only a primary parsed under the transaction lock may replace the backup. During recovery the + // primary is corrupt or absent, so the known-good backup must survive until publication. + if (options.primaryStatus === 'validated') { + await copyFileDurable(filePath, backupPath(filePath)) + } + await renameDurable(tmpPath, filePath) + } catch (error) { + await rm(tmpPath, { force: true }).catch(() => {}) + throw error + } +} diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts new file mode 100644 index 00000000000..f70145cf5a2 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-security.test.ts @@ -0,0 +1,62 @@ +import { chmod, mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +let directory: string + +function reserveRequest(): AgentSessionReserveRequest { + return { + sessionId: 'session-created', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-created', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-created', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-security-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('agent session record store security', () => { + it.skipIf(process.platform === 'win32')( + 'creates the directory and store owner-only', + async () => { + const nestedDirectory = join(directory, 'agent-sessions') + await chmod(directory, 0o755) + const store = await AgentSessionRecordStore.open({ + directory: nestedDirectory, + hostId: 'local' + }) + await store.reserveOwner(reserveRequest()) + + expect((await stat(nestedDirectory)).mode & 0o777).toBe(0o700) + expect((await stat(agentSessionStorePath(nestedDirectory))).mode & 0o777).toBe(0o600) + } + ) +}) diff --git a/src/main/runtime/agent-session-record-store-security.ts b/src/main/runtime/agent-session-record-store-security.ts new file mode 100644 index 00000000000..cc9b302ebfa --- /dev/null +++ b/src/main/runtime/agent-session-record-store-security.ts @@ -0,0 +1,40 @@ +import { chmod, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + loadAgentSessionStore, + type LoadedAgentSessionStore +} from './agent-session-record-store-file' +import { withAgentSessionStoreTransactionLock } from './agent-session-store-transaction-lock' + +const OWNER_DIRECTORY_MODE = 0o700 +const OWNER_FILE_MODE = 0o600 + +async function chmodIfPresent(path: string, mode: number): Promise { + try { + await chmod(path, mode) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +export async function hardenAgentSessionStorePermissions(filePath: string): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: OWNER_DIRECTORY_MODE }) + await chmod(directory, OWNER_DIRECTORY_MODE) + await Promise.all([ + chmodIfPresent(filePath, OWNER_FILE_MODE), + chmodIfPresent(`${filePath}.bak`, OWNER_FILE_MODE) + ]) +} + +export async function loadProtectedAgentSessionStore( + filePath: string, + hostId: string +): Promise { + return withAgentSessionStoreTransactionLock(filePath, async () => { + await hardenAgentSessionStorePermissions(filePath) + return loadAgentSessionStore(filePath, hostId) + }) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts new file mode 100644 index 00000000000..4b325fa0ff3 --- /dev/null +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -0,0 +1,879 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionExecutionLocation, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions' +import { + AGENT_SESSION_CLAIM_KEY_RETENTION_MS, + AgentSessionRecordStore +} from './agent-session-record-store' +import { + agentSessionStorePath, + AGENT_SESSION_STORE_FILE_NAME +} from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 + +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const WSL: AgentSessionExecutionLocation = { ...NATIVE, wslDistro: 'Ubuntu-22.04' } +const SSH: AgentSessionExecutionLocation = { ...NATIVE, executionHostId: 'ssh:build-box' } +const FOLDER: AgentSessionExecutionLocation = { + ...NATIVE, + workspaceId: 'workspace-2', + workspaceKind: 'folder' +} + +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } +const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' } +const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}' +const BAD_KEY_STORE = + '{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}' + +let counter = 0 + +function operationId(now = NOW): string { + counter += 1 + return `${now}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +function processIdentity( + overrides: Partial = {} +): AgentSessionProcessIdentity { + return { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-a', + ...overrides + } +} + +function handleLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +let directory: string + +async function open(hostId = 'local'): Promise { + return AgentSessionRecordStore.open({ directory, hostId }) +} + +/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ +async function establishOwner( + store: AgentSessionRecordStore, + overrides: Partial = {} +): Promise { + const reserved = await store.reserveOwner(reserveRequest(overrides)) + const fence = reserved.record.lease.runtimeFence + const sessionId = reserved.record.sessionId + await store.commitProcessIdentity({ + sessionId, + fence, + process: processIdentity({ spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' }), + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: handleLink({ mintedAtFence: fence }), + now: NOW + }) +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('acquisition path', () => { + it('admits a writer only after reservation, observed identity, and a proved handle', async () => { + const store = await open() + const reserved = await store.reserveOwner(reserveRequest()) + expect(reserved.disposition).toBe('created') + expect(reserved.record.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: null, + reservedSpawnToken: 'spawn-a' + }) + + // Proving before the spawn is observed is refused. + await expect( + store.proveOwner({ sessionId: 'session-alpha', fence: 1, link: handleLink(), now: NOW }) + ).rejects.toThrow('agent_session_ownership_unknown') + + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity(), + now: NOW + }) + const proved = await store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink(), + now: NOW + }) + expect(proved.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + provenHandleLinkId: 'link-1', + runtimeFence: 1 + }) + expect(proved.providerHandleChain).toHaveLength(1) + }) + + it('refuses a child that cannot echo the reserved spawn token', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await expect( + store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity({ spawnToken: 'spawn-other' }), + now: NOW + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('accepts provider proof only for the reserved provider at the current fence', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity(), + now: NOW + }) + + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ + handle: { provider: 'codex', threadId: 'thread-1' } + }), + now: NOW + }) + ).rejects.toThrow('agent_session_provider_handle_provider_mismatch') + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ mintedAtFence: 2 }), + now: NOW + }) + ).rejects.toThrow('agent_session_provider_handle_stale_fence') + }) + + it('does not let an established owner re-enter the proof transition', async () => { + const store = await open() + await establishOwner(store) + + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ + linkId: 'link-2', + origin: 'resumed', + observedAt: NOW + 1 + }), + now: NOW + 1 + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('refuses a create that carries a fence and a re-create that does not', async () => { + const store = await open() + await expect(store.reserveOwner(reserveRequest({ expectedFence: 0 }))).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + await establishOwner(store) + await expect(store.reserveOwner(reserveRequest({ expectedFence: null }))).rejects.toThrow( + 'agent_session_conflict' + ) + }) + + it.each([ + [ + 'provider', + { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } } + ], + ['account', { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-other' } }] + ] as const)("refuses to change a session's pinned %s", async (_name, overrides) => { + const store = await open() + await establishOwner(store) + await expect( + store.reserveOwner( + reserveRequest({ + ...overrides, + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) +}) + +describe('concurrent claims', () => { + it('lets only one store instance reserve a session from the same disk snapshot', async () => { + const [first, second] = await Promise.all([open(), open()]) + const results = await Promise.allSettled([ + first.reserveOwner(reserveRequest()), + second.reserveOwner(reserveRequest()) + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const refused = results.find((result) => result.status === 'rejected') + expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_conflict') + + const persisted = await open() + expect(persisted.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(persisted.listOperationRows()).toHaveLength(1) + }) + + it('lets exactly one of two concurrent reservations win and never spawns the loser', async () => { + const store = await open() + await establishOwner(store) + const request = () => + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + spawnToken: 'spawn-b', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + const results = await Promise.allSettled([ + store.reserveOwner(request()), + store.reserveOwner(request()) + ]) + const granted = results.filter((result) => result.status === 'fulfilled') + expect(granted).toHaveLength(1) + const refused = results.find((result) => result.status === 'rejected') + expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_checkpoint_stale') + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) + }) + + it('serializes concurrent creates of the same session id', async () => { + const store = await open() + const results = await Promise.allSettled([ + store.reserveOwner(reserveRequest()), + store.reserveOwner(reserveRequest()) + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + }) + + it('replays a retried operation id instead of reserving twice', async () => { + const store = await open() + const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } + const first = await store.reserveOwner(reserveRequest({ operation })) + const second = await store.reserveOwner(reserveRequest({ operation })) + expect(second.disposition).toBe('replayed') + expect(second.record.lease.runtimeFence).toBe(first.record.lease.runtimeFence) + expect(store.listOperationRows()).toHaveLength(1) + }) + + it('refuses the same operation id carrying different parameters', async () => { + const store = await open() + const operationId_ = operationId() + await store.reserveOwner( + reserveRequest({ + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } + }) + ) + await expect( + store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-9' } + }) + ) + ).rejects.toThrow('agent_session_operation_conflict') + }) + + it('rolls the in-memory state back when a transaction throws', async () => { + const store = await open() + await establishOwner(store) + const before = store.getRecord('session-alpha') + await expect( + store.reserveOwner(reserveRequest({ expectedFence: 1, probe: INDETERMINATE })) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(store.getRecord('session-alpha')).toEqual(before) + expect(store.listOperationRows()).toHaveLength(1) + }) + + it('never keeps a change in memory that failed to commit to disk', async () => { + const store = await open() + await establishOwner(store) + const before = store.getRecord('session-alpha') + // Losing both committed copies must not reset the live store to empty authority. + await rm(directory, { recursive: true, force: true }) + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 9, sequence: 9 }, + now: NOW + }) + ).rejects.toThrow() + expect(store.getRecord('session-alpha')).toEqual(before) + expect(store.getRecord('session-alpha')?.lease.journalCheckpoint).toBeNull() + }) +}) + +describe('expiry is not eviction', () => { + it('never grants a second owner on a lapsed deadline alone', async () => { + const store = await open() + const owned = await establishOwner(store) + const wellPastDeadline = owned.lease.leaseDeadlineAt + 60 * 60 * 1000 + for (const probe of [INDETERMINATE, MATCHED] as const) { + await expect( + store.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe, + now: wellPastDeadline, + operation: { + callerKey: 'client-1', + operationId: operationId(wellPastDeadline), + fingerprint: 'fp-2' + } + }) + ) + ).rejects.toThrow(/agent_session_(ownership_unknown|conflict)/) + } + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + + // Only proof of death moves it. + const evicted = await store.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: wellPastDeadline + }) + expect(evicted.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released' }) + expect(evicted.lease.deathEvidence?.kind).toBe('pid-absent') + }) + + it('refuses to evict an owner it cannot prove dead', async () => { + const store = await open() + await establishOwner(store) + await expect( + store.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: INDETERMINATE, + now: NOW + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('stops renewing a lease it can no longer vouch for', async () => { + const store = await open() + await establishOwner(store) + const renewed = await store.renewLease({ + sessionId: 'session-alpha', + fence: 1, + childProbe: MATCHED, + now: NOW + 5_000 + }) + expect(renewed.lease.lastRenewedAt).toBe(NOW + 5_000) + await expect( + store.renewLease({ + sessionId: 'session-alpha', + fence: 1, + childProbe: { outcome: 'identity-matched', matchedOn: [] }, + now: NOW + 10_000 + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) +}) + +describe('restart reconciliation', () => { + it('survives a restart and grants no writer until adjudicated', async () => { + const first = await open() + await establishOwner(first) + + const reopened = await open() + const loaded = reopened.getRecord('session-alpha') + expect(loaded?.lease.unreconciled).toBe(true) + expect(loaded?.providerHandleChain).toHaveLength(1) + expect(loaded?.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/home/dev/.claude-work' + }) + // Every mutating path is closed while unreconciled. + await expect( + reopened.renewLease({ sessionId: 'session-alpha', fence: 1, childProbe: MATCHED, now: NOW }) + ).rejects.toThrow('execution_owner_reconciling') + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('execution_owner_reconciling') + }) + + it('re-adopts a live owner without moving the fence', async () => { + const first = await open() + await establishOwner(first) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) + const record = reopened.getRecord('session-alpha') + expect(record?.lease).toMatchObject({ + unreconciled: false, + runtimeFence: 1, + claimStatus: 'live' + }) + expect(record?.lease.provenHandleLinkId).toBe('link-1') + }) + + it('never applies a stale restart probe to a replacement owner', async () => { + const writer = await open() + await establishOwner(writer) + const reconciler = await open() + let releaseProbe!: (probe: AgentSessionOwnerProbe) => void + let markProbeStarted!: () => void + const probeStarted = new Promise((resolve) => (markProbeStarted = resolve)) + const probeResult = new Promise((resolve) => (releaseProbe = resolve)) + const reconciliation = reconciler.reconcileOnRestart({ + probe: async () => { + markProbeStarted() + return probeResult + }, + now: NOW + 1_000 + }) + + await probeStarted + await writer.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: NOW + 100 + }) + const replacement = await writer.reserveOwner( + reserveRequest({ + expectedFence: 2, + probe: UNUSED, + spawnToken: 'spawn-b', + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 200), + fingerprint: 'fp-2' + }, + now: NOW + 200 + }) + ) + await writer.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: replacement.record.lease.runtimeFence, + process: processIdentity({ pid: 5252, spawnToken: 'spawn-b' }), + now: NOW + 300 + }) + await writer.proveOwner({ + sessionId: 'session-alpha', + fence: replacement.record.lease.runtimeFence, + link: handleLink({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }), + now: NOW + 300 + }) + + releaseProbe({ outcome: 'pid-absent' }) + expect(await reconciliation).toEqual(new Map()) + const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + expect(persisted.records['session-alpha'].lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + ownerProcess: { pid: 5252, spawnToken: 'spawn-b' }, + unreconciled: false + }) + }) + + it('keeps the fence monotonic across a restart and never reuses a retired fence', async () => { + const first = await open() + await establishOwner(first) + await first.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'exit-observed' }, + now: NOW + }) + + const second = await open() + await second.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) + const afterRestart = second.getRecord('session-alpha')?.lease.runtimeFence ?? 0 + expect(afterRestart).toBeGreaterThanOrEqual(2) + + const reacquired = await second.reserveOwner( + reserveRequest({ + expectedFence: afterRestart, + probe: UNUSED, + spawnToken: 'spawn-b', + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 1_000), + fingerprint: 'fp-2' + }, + now: NOW + 1_000 + }) + ) + expect(reacquired.record.lease.runtimeFence).toBe(afterRestart + 1) + + const third = await open() + expect(third.getRecord('session-alpha')?.lease.runtimeFence).toBe(afterRestart + 1) + }) + + it('sends an unverifiable owner to recovery rather than releasing it', async () => { + const first = await open() + await establishOwner(first) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => INDETERMINATE, now: NOW + 1_000 }) + const lease = reopened.getRecord('session-alpha')?.lease + expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 1 }) + expect(lease?.ownerProcess).not.toBeNull() + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 1_000), + fingerprint: 'fp-2' + }, + now: NOW + 1_000 + }) + ) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('keeps a conflict conflicted across a restart that proves nothing', async () => { + const first = await open() + await establishOwner(first) + await first.markClaimConflicted('session-alpha', NOW) + + const reopened = await open() + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), + now: NOW + }) + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) + + it('releases a conflict whose named owner is proven gone at restart', async () => { + // A conflict with no exit is a session the user can never open again; present-time proof that + // the process the conflict names has exited leaves no claimant left to protect. + const first = await open() + await establishOwner(first) + await first.markClaimConflicted('session-alpha', NOW) + + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW }) + + const lease = reopened.getRecord('session-alpha')?.lease + expect(lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'pid-absent' } + }) + const reacquired = await reopened.reserveOwner( + reserveRequest({ + expectedFence: lease?.runtimeFence ?? null, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + expect(reacquired.disposition).toBe('reserved') + }) + + it('frees a reservation that provably never spawned', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 2, + reservedSpawnToken: null + }) + }) + + it('carries the operation ledger across a restart so a retry is still a replay', async () => { + const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } + const first = await open() + await first.reserveOwner(reserveRequest({ operation })) + await first.recordOperationOutcome({ + callerKey: operation.callerKey, + operationId: operation.operationId, + outcome: { status: 'succeeded', sessionId: 'session-alpha' } + }) + + const reopened = await open() + expect(reopened.listOperationRows()).toHaveLength(1) + const replayed = await reopened.reserveOwner(reserveRequest({ operation })) + expect(replayed.disposition).toBe('replayed') + expect(replayed.record.sessionId).toBe('session-alpha') + }) +}) + +describe('host and workspace isolation', () => { + it.each([ + ['WSL', WSL], + ['SSH', SSH], + ['another workspace', FOLDER], + ['another workspace kind', { ...NATIVE, workspaceKind: 'folder' }] + ] as const)('refuses to move one session id to %s', async (_name, location) => { + const store = await open() + await establishOwner(store) + await expect( + store.reserveOwner( + reserveRequest({ + location, + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) + + it('keeps native, WSL, and SSH sessions in separate scopes', async () => { + const store = await open() + await establishOwner(store, { sessionId: '__proto__' }) + await establishOwner(store, { sessionId: 'session-wsl', location: WSL, spawnToken: 'spawn-b' }) + await establishOwner(store, { sessionId: 'session-ssh', location: SSH, spawnToken: 'spawn-c' }) + await establishOwner(store, { + sessionId: 'session-folder', + location: FOLDER, + spawnToken: 'spawn-d' + }) + + expect(store.listByScope(NATIVE).map((record) => record.sessionId)).toEqual(['__proto__']) + expect(store.listByScope(WSL).map((record) => record.sessionId)).toEqual(['session-wsl']) + expect(store.listByScope(SSH).map((record) => record.sessionId)).toEqual(['session-ssh']) + expect(store.listByScope(FOLDER).map((record) => record.sessionId)).toEqual(['session-folder']) + expect((await open()).getRecord('__proto__')).not.toBeNull() + }) + + it('preserves the workspace kind so a folder workspace is never read back as a worktree', async () => { + const first = await open() + await establishOwner(first, { sessionId: 'session-folder', location: FOLDER }) + const reopened = await open() + expect(reopened.getRecord('session-folder')?.location).toEqual(FOLDER) + }) +}) + +describe('orphans, claim keys, checkpoints, and unreadable rows', () => { + it('calls a spawn token with no lease an orphan', async () => { + const store = await open() + await establishOwner(store) + expect(store.listOrphanSpawnTokens(['spawn-a', 'spawn-z'])).toEqual(['spawn-z']) + }) + + it('keeps a retired claim key verifiable for the retention window', async () => { + const store = await open() + await store.retireClaimKey('key-1', NOW) + expect(store.isClaimKeyVerifiable('key-1', NOW + AGENT_SESSION_CLAIM_KEY_RETENTION_MS)).toBe( + true + ) + expect( + store.isClaimKeyVerifiable('key-1', NOW + AGENT_SESSION_CLAIM_KEY_RETENTION_MS + 1) + ).toBe(false) + expect(store.isClaimKeyVerifiable('key-unknown', NOW)).toBe(true) + }) + + it('refuses a journal checkpoint that moves backwards', async () => { + const store = await open() + await establishOwner(store) + await store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 2, sequence: 10 }, + now: NOW + }) + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 2, sequence: 9 }, + now: NOW + }) + ).rejects.toThrow('agent_session_checkpoint_stale') + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 999 }, + now: NOW + }) + ).rejects.toThrow('agent_session_checkpoint_stale') + const advanced = await store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 3, sequence: 0 }, + now: NOW + }) + expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) + }) + + it('rejects a handoff stage change under a different operation id', async () => { + const store = await open() + await establishOwner(store) + await setStoredAgentSessionHandoffStage(store, { + sessionId: 'session-alpha', + fence: 1, + stage: 'preparing', + handoffOperationId: 'op-1', + now: NOW + }) + await expect( + setStoredAgentSessionHandoffStage(store, { + sessionId: 'session-alpha', + fence: 1, + stage: 'old-owner-stopped', + handoffOperationId: 'op-2', + now: NOW + }) + ).rejects.toThrow('agent_session_operation_conflict') + }) + + it.each([ + [ + 'invalid checkpoint', + (record: AgentSessionRecord) => + Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) + ], + [ + 'missing live proof', + (record: AgentSessionRecord) => Object.assign(record.lease, { provenHandleLinkId: null }) + ] + ])('quarantines a record with %s', async (_name, corrupt) => { + const first = await open() + await establishOwner(first) + const filePath = agentSessionStorePath(directory) + const raw = JSON.parse(await readFile(filePath, 'utf-8')) + corrupt(raw.records['session-alpha']) + await writeFile(filePath, JSON.stringify(raw)) + expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) + }) + + it('recovers the previous committed state when the primary file is corrupt', async () => { + const first = await open() + await establishOwner(first) + // A second commit leaves the first as the backup. + await first.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 1 }, + now: NOW + }) + await writeFile(join(directory, AGENT_SESSION_STORE_FILE_NAME), '{ truncated') + + const reopened = await open() + expect(reopened.recoveredFromBackup).toBe(true) + expect(reopened.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + + // The next transaction completes. It used to reject forever: the latch that guarded against + // the lost commit's fence had no exit, so a profile in this state could never write again. + await expect(reopened.retireClaimKey('key-2', NOW)).resolves.not.toThrow() + // Safety is kept by recording a FLOOR the next grant must clear, not by rewriting the current + // fence: `live` means a handle proven at exactly that number, so moving it would invalidate the + // record. The floor dominates the highest fence the lost commit could have granted (1 + 1). + const recovered = reopened.getRecord('session-alpha') + expect(recovered?.lease.runtimeFence).toBe(1) + expect(recovered?.lease.minimumNextFence).toBe(3) + }) + + it.each([ + ['corrupt', ['{ truncated']], + ['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']], + ['invalid operation row', [BAD_OP_STORE]], + ['invalid retired key', [BAD_KEY_STORE]], + ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] + ])('fails closed when the store is %s', async (_name, copies) => { + const filePath = agentSessionStorePath(directory) + await writeFile(filePath, copies[0]) + if (copies[1]) { + await writeFile(`${filePath}.bak`, copies[1]) + } + await expect(open()).rejects.toThrow('agent_session_store_corrupt') + }) + + it('refuses to write a store written by a newer schema', async () => { + const filePath = agentSessionStorePath(directory) + await writeFile( + filePath, + JSON.stringify({ schemaVersion: 99, hostId: 'local', records: {}, operations: {} }) + ) + const store = await open() + expect(store.readOnly).toBe(true) + await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( + 'agent_session_legacy_required' + ) + }) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts new file mode 100644 index 00000000000..85f77596010 --- /dev/null +++ b/src/main/runtime/agent-session-record-store.ts @@ -0,0 +1,328 @@ +/** Durable single-writer session records and their operation ledger. */ + +import { + agentSessionOperationKey, + settleAgentSessionOperation, + type AgentSessionOperationDecision, + type AgentSessionOperationOutcome, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionOperationRow, + type AgentSessionOperationAdmission +} from './agent-session-operation-admission' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + agentSessionScopeKey, + type AgentSessionExecutionLocation, + type AgentSessionJournalCheckpoint, + type AgentSessionOptionsReplacement, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { + commitAgentSessionProcessIdentity, + evictAgentSessionOwner, + proveAgentSessionOwner, + setAgentSessionJournalCheckpoint, + type AgentSessionProcessIdentityCommit +} from './agent-session-lease-transitions' +import { + settleFailedAgentSessionAcquisition, + settleFailedAgentSessionPostAcquisitionAttachment, + type AgentSessionFailedAcquisitionSettlement, + type AgentSessionFailedPostAcquisitionAttachmentSettlement +} from './agent-session-acquisition-failure-settlement' +import { + renewAgentSessionLeases, + type AgentSessionLeaseRenewal +} from './agent-session-lease-renewal' +import { + applyAgentSessionRestartProbes, + collectAgentSessionRestartProbes, + type AgentSessionRestartProbeArgs +} from './agent-session-restart-reconciliation' +import { replaceAgentSessionRecordOptions } from './agent-session-record-options' +import { + setAgentSessionReservationProcesslessProof, + type AgentSessionReservationProcesslessProof +} from './agent-session-processless-reservation' +import { + admitPendingAgentSessionReservationReplay, + applyAgentSessionReservation, + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + type AgentSessionReserveRequest, + type AgentSessionReserveResult +} from './agent-session-reservation-admission' +import { + agentSessionStoreRevision, + agentSessionStorePath, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' +import { + AgentSessionStoreTransactionQueue, + markAgentSessionStoreLeasesUnreconciled +} from './agent-session-store-transaction-queue' + +export const AGENT_SESSION_LEASE_TTL_MS = 30_000, + AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 +/** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */ +export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 + +export class AgentSessionRecordStore { + private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} + + static async open(args: { directory: string; hostId: string }): Promise { + const filePath = agentSessionStorePath(args.directory) + const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) + // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart + // grants no writer on the strength of what the previous process wrote. + const diskRevision = agentSessionStoreRevision(loaded.state) + markAgentSessionStoreLeasesUnreconciled(loaded.state) + const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( + filePath, + args.hostId, + loaded, + diskRevision + ) + if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { + await transactions.persistLoadedRewrite() + } + return new AgentSessionRecordStore(transactions) + } + + private get state(): AgentSessionStoreState { + return this.transactions.state + } + + get readOnly(): boolean { + return this.transactions.readOnly + } + + get recoveredFromBackup(): boolean { + return this.transactions.recoveredFromBackup + } + + get hostId(): string { + return this.state.hostId + } + + getRecord = (sessionId: string): AgentSessionRecord | null => + this.state.records.get(sessionId) ?? null + + listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + + listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { + const scope = agentSessionScopeKey(location) + return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) + } + + /** A record this build cannot validate: readable as present, never grantable as a writer. */ + isSessionUnreadable(sessionId: string): boolean { + return this.state.unreadableRecords.has(sessionId) + } + + listOperationRows = (): AgentSessionOperationRow[] => [...this.state.operations.values()] + + isClaimKeyVerifiable(keyId: string, now: number): boolean { + const retired = this.state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + } + + /** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */ + listOrphanSpawnTokens(observedTokens: readonly string[]): string[] { + const leases = this.listRecords().map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) + } + + /** + * Compare-and-swap reservation plus its client-operation row, committed together. A replayed + * operation returns the recorded outcome and never reaches the reservation. + */ + async reserveOwner(request: AgentSessionReserveRequest): Promise { + return this.transact(() => { + const decision = evaluateAgentSessionReserveOperation(this.state, request) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision === 'replay') { + let record = requireAgentSessionRecordForReplay(this.state, decision.row, request.sessionId) + if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { + record = admitPendingAgentSessionReservationReplay(record, request) + } + return { record, disposition: 'replayed' as const, operationRow: decision.row } + } + const result = applyAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + this.state.operations.set( + agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), + decision.row + ) + this.state.records.set(result.record.sessionId, result.record) + return { ...result, operationRow: decision.row } + }) + } + + async commitProcessIdentity( + args: AgentSessionProcessIdentityCommit + ): Promise { + return this.mutate(args.sessionId, (record) => + commitAgentSessionProcessIdentity({ ...args, record }) + ) + } + + setReservationProcesslessProof = ( + args: AgentSessionReservationProcesslessProof & { processlessAt: number | null } + ): Promise => + this.mutate(args.sessionId, (record) => + setAgentSessionReservationProcesslessProof({ ...args, record }) + ) + + async proveOwner(args: { + sessionId: string + fence: number + link: AgentSessionProviderHandleLink + now: number + leaseTtlMs?: number + options?: Readonly> + }): Promise { + return this.mutate(args.sessionId, (record) => { + const proved = proveAgentSessionOwner({ + record, + fence: args.fence, + link: args.link, + now: args.now, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + return args.options + ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) + : proved + }) + } + + /** Settle the failed attach and its reservation in one durable transaction. */ + settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => + this.transact(() => settleFailedAgentSessionAcquisition(this.state, args)) + + settleFailedPostAcquisitionAttachment = ( + args: AgentSessionFailedPostAcquisitionAttachmentSettlement + ) => this.transact(() => settleFailedAgentSessionPostAcquisitionAttachment(this.state, args)) + + async renewLease(args: AgentSessionLeaseRenewal): Promise { + const [renewed] = await this.renewLeases([args]) + return renewed + } + + async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { + return this.transact(() => + renewAgentSessionLeases(this.state, renewals, AGENT_SESSION_LEASE_TTL_MS) + ) + } + + async evictProvenDeadOwner(args: { + sessionId: string + expectedFence: number + probe: AgentSessionOwnerProbe + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) + } + + async transitionHandoff( + sessionId: string, + transition: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.mutate(sessionId, transition) + } + + async setJournalCheckpoint(args: { + sessionId: string + fence: number + checkpoint: AgentSessionJournalCheckpoint + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => + setAgentSessionJournalCheckpoint({ ...args, record }) + ) + } + + /** Adjudicate every lease this host loaded. No lease grants a writer until it appears here. */ + async reconcileOnRestart( + args: AgentSessionRestartProbeArgs + ): Promise> { + const pending = this.listRecords().filter((record) => record.lease.unreconciled) + const probes = await collectAgentSessionRestartProbes(pending, args) + return this.transact(() => applyAgentSessionRestartProbes(this.state, probes, args.now)) + } + + /** Admits one non-reservation mutation through the durable ledger. */ + async admitOperation( + args: AgentSessionOperationAdmission + ): Promise { + return this.transact(() => { + const admitted = admitAgentSessionOperationRow(this.state.operations, args) + this.state.operations = admitted.rows + return admitted.decision + }) + } + + async recordOperationOutcome(args: { + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + }): Promise { + await this.transact(() => { + this.state.operations = settleAgentSessionOperation(this.state.operations, args) + }) + } + + async markClaimConflicted(sessionId: string, now: number): Promise { + return this.mutate(sessionId, (record) => ({ + ...record, + updatedAt: now, + // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free + // merely because the process that observed the conflict is gone. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + })) + } + + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => + this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) + + async retireClaimKey(keyId: string, now: number): Promise { + await this.transact(() => { + if (!this.state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + this.state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + this.state.retiredClaimKeys = this.state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + ) + }) + } + + private async mutate( + sessionId: string, + apply: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.transact(() => { + const record = this.state.records.get(sessionId) + if (!record) { + throw new Error( + this.isSessionUnreadable(sessionId) + ? 'execution_owner_reconciling' + : 'agent_session_identity_required' + ) + } + const next = apply(record) + this.state.records.set(sessionId, next) + return next + }) + } + + /** Serialize every mutation against the latest committed disk state. */ + private transact = (apply: () => T): Promise => this.transactions.transact(apply) +} diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts new file mode 100644 index 00000000000..1f807870682 --- /dev/null +++ b/src/main/runtime/agent-session-record-unsupported-schema.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION_ID = 'session-alpha-1' +let directory: string + +function reserveRequest(): AgentSessionReserveRequest { + return { + sessionId: SESSION_ID, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-unsupported-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('unsupported agent session record schema', () => { + it('quarantines without upgrading and keeps the session fail-closed', async () => { + const filePath = agentSessionStorePath(directory) + const unsupported = { ...agentSessionRecordFixture(), schemaVersion: 1 } + const payload = JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: 'local', + records: { [SESSION_ID]: unsupported }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + await Promise.all([writeFile(filePath, payload), writeFile(`${filePath}.bak`, payload)]) + + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + expect(store.getRecord(SESSION_ID)).toBeNull() + expect(store.isSessionUnreadable(SESSION_ID)).toBe(true) + await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( + 'execution_owner_reconciling' + ) + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records).not.toHaveProperty(SESSION_ID) + expect(persisted.unusableRecords[SESSION_ID]).toMatchObject({ + reason: 'unsupported_schema', + raw: { schemaVersion: 1 } + }) + }) + + it('rejects an ad-hoc store schema without rewriting it', async () => { + const filePath = agentSessionStorePath(directory) + const payload = JSON.stringify({ + schemaVersion: 1, + hostId: 'local', + records: {}, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + await writeFile(filePath, payload) + + await expect(AgentSessionRecordStore.open({ directory, hostId: 'local' })).rejects.toThrow( + 'agent_session_store_corrupt' + ) + await expect(readFile(filePath, 'utf-8')).resolves.toBe(payload) + }) +}) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts new file mode 100644 index 00000000000..37ad172bdfa --- /dev/null +++ b/src/main/runtime/agent-session-recovery-publish-fault.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' +import { + agentSessionStoreRevision, + loadAgentSessionStore, + saveAgentSessionStore, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' + +const publishFault = vi.hoisted(() => ({ armed: false })) + +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + renameDurable: async (tmpPath: string, finalPath: string) => { + if (publishFault.armed) { + publishFault.armed = false + throw new Error('simulated death before primary publish') + } + return actual.renameDurable(tmpPath, finalPath) + } + } +}) + +let root: string +let storePath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-')) + storePath = join(root, 'agent-sessions.json') +}) + +afterEach(async () => { + publishFault.armed = false + await rm(root, { recursive: true, force: true }) +}) + +function state(generation: number): AgentSessionStoreState { + return { + schemaVersion: 2, + hostId: 'local', + records: new Map(), + operations: new Map(), + retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], + unreadableRecords: new Map() + } +} + +describe('backup recovery publication', () => { + it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => { + await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' }) + await saveAgentSessionStore(storePath, state(2), { + primaryStatus: 'validated' + }) + await writeFile(storePath, '{corrupt-primary', 'utf-8') + const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8') + + const recovered = await loadAgentSessionStore(storePath, 'local') + expect(recovered.recoveredFromBackup).toBe(true) + expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') + const queue = AgentSessionStoreTransactionQueue.fromLoadedStore( + storePath, + 'local', + recovered, + agentSessionStoreRevision(recovered.state) + ) + + publishFault.armed = true + await expect(queue.persistLoadedRewrite()).rejects.toThrow( + 'simulated death before primary publish' + ) + + expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup) + const afterFault = await loadAgentSessionStore(storePath, 'local') + expect(afterFault.recoveredFromBackup).toBe(true) + expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts new file mode 100644 index 00000000000..1735d67e62c --- /dev/null +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -0,0 +1,211 @@ +/** + * Reservation admission: what a reserve request means against the persisted state. + * + * Pure over a store snapshot so the compare-and-swap, the idempotency replay, and the + * location-immutability check can be reasoned about without touching the disk. The store applies + * the result inside one transaction; nothing here mutates. + */ + +import { + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + agentSessionExecutionLocationsEqual, + isAgentSessionLaunchArgs, + isAgentSessionLaunchEnv, + type AgentSessionAccountHome, + type AgentSessionExecutionLocation, + type AgentSessionLaunchArgs, + type AgentSessionLaunchEnv, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { + reserveAgentSessionOwner, + type AgentSessionReservation +} from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionReserveRequest = { + sessionId: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + accountHome: AgentSessionAccountHome + /** Arguments pinned on first reservation so owner replacement repeats the same launch. */ + launchArgs?: AgentSessionLaunchArgs + /** Current launch input validated here but never written to the durable record. */ + launchEnv?: AgentSessionLaunchEnv + runtimeKind: AgentSessionReservation['runtimeKind'] + /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ + expectedFence: number | null + /** A supplier is invoked only when this operation wins a new reservation. */ + spawnToken: string | (() => string) + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe + operation: { callerKey: string; operationId: string; fingerprint: string } + now: number + leaseTtlMs?: number +} + +export type AgentSessionReserveDisposition = + | 'created' + | 'reserved' + | 'retry-reservation' + | 'replayed' + +export type AgentSessionReserveResult = { + record: AgentSessionRecord + disposition: AgentSessionReserveDisposition + operationRow: AgentSessionOperationRow +} + +export function evaluateAgentSessionReserveOperation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): AgentSessionOperationDecision { + state.operations = pruneAgentSessionOperationRows(state.operations, request.now) + return evaluateAgentSessionOperation({ + rows: state.operations, + callerKey: request.operation.callerKey, + operationId: request.operation.operationId, + fingerprint: request.operation.fingerprint, + now: request.now + }) +} + +export function requireAgentSessionRecordForReplay( + state: AgentSessionStoreState, + row: AgentSessionOperationRow, + sessionId: string +): AgentSessionRecord { + const replayedId = row.outcome.status === 'succeeded' ? row.outcome.sessionId : sessionId + const record = state.records.get(replayedId) + if (!record) { + // Why: the recorded effect is no longer reconstructable, and re-running it would be a second + // spawn rather than a replay. + throw new Error('agent_session_ownership_unknown') + } + return record +} + +export function admitPendingAgentSessionReservationReplay( + record: AgentSessionRecord, + request: AgentSessionReserveRequest +): AgentSessionRecord { + const decision = evaluateAgentSessionAcquisition({ + lease: record.lease, + expectedFence: record.lease.runtimeFence, + handoffOperationId: request.handoffOperationId, + probe: request.probe + }) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision !== 'retry-reservation') { + // A replay may continue only its still-present reservation; recovery requires a fresh intent. + throw new Error('agent_session_ownership_unknown') + } + return record +} + +export function applyAgentSessionReservation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + leaseTtlMs: number +): { + record: AgentSessionRecord + disposition: Exclude +} { + if (request.launchEnv && !isAgentSessionLaunchEnv(request.launchEnv)) { + throw new Error('agent_session_launch_env_invalid') + } + if (request.launchArgs && !isAgentSessionLaunchArgs(request.launchArgs)) { + throw new Error('agent_session_launch_args_invalid') + } + const reservation: AgentSessionReservation = { + runtimeKind: request.runtimeKind, + spawnToken: + typeof request.spawnToken === 'function' ? request.spawnToken() : request.spawnToken, + claimKeyId: request.claimKeyId, + handoffOperationId: request.handoffOperationId, + leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs, + now: request.now + } + const existing = state.records.get(request.sessionId) + if (!existing) { + if (state.unreadableRecords.has(request.sessionId)) { + throw new Error('execution_owner_reconciling') + } + if (request.expectedFence !== null) { + throw new Error('agent_session_checkpoint_stale') + } + return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } + } + if ( + !agentSessionExecutionLocationsEqual(existing.location, request.location) || + existing.provider !== request.provider || + existing.accountHome.variable !== request.accountHome.variable || + existing.accountHome.path !== request.accountHome.path + ) { + // Why: location, provider, and account are the session identity; changing one is a fork. + throw new Error('agent_session_conflict') + } + if (request.expectedFence === null) { + throw new Error('agent_session_conflict') + } + const pinned = { + ...existing, + ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), + ...(!existing.launchArgs && request.launchArgs ? { updatedAt: request.now } : {}) + } + return reserveAgentSessionOwner({ + record: pinned, + expectedFence: request.expectedFence, + probe: request.probe, + reservation + }) +} + +function createAgentSessionRecord( + request: AgentSessionReserveRequest, + reservation: AgentSessionReservation +): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: request.sessionId, + location: request.location, + provider: request.provider, + providerHandleChain: [], + accountHome: request.accountHome, + ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), + createdAt: request.now, + updatedAt: request.now, + lease: { + sessionId: request.sessionId, + runtimeKind: reservation.runtimeKind, + // Why: fence 1 is the first reservation; 0 is reserved for "no owner has ever existed". + runtimeFence: 1, + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: reservation.spawnToken, + leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, + lastRenewedAt: reservation.now, + handoffOperationId: reservation.handoffOperationId, + journalCheckpoint: null, + claimKeyId: reservation.claimKeyId, + claimStatus: 'reserved', + unreconciled: false, + deathEvidence: null + } + } +} diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts new file mode 100644 index 00000000000..0a4e3c39a54 --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' + +const NOW = 1_800_000_000_000 + +function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: 'session-restart', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-restart', + runtimeKind: stage === 'preparing' ? 'native' : 'tui', + runtimeFence: 4, + handoffStage: stage, + provenHandleLinkId: null, + ownerProcess: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-restart' + }, + reservedSpawnToken: 'spawn-restart', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'handoff-op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: stage === 'preparing' ? 'live' : 'reserved', + unreconciled: true, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +describe('restarted handoff adjudication', () => { + it('continues a dead preparing owner at old-owner-stopped under the same operation', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('preparing'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1', + claimStatus: 'released', + ownerProcess: null + }) + }) + + it('routes an ownerless indeterminate reservation to manual recovery at the same fence', () => { + const abandoned = record('new-owner-proving') + abandoned.lease.runtimeKind = 'native' + abandoned.lease.ownerProcess = null + expect( + adjudicateRestartedAgentSessionHandoff( + abandoned, + { outcome: 'indeterminate', reason: 'no spawn-token scan' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'manual-recovery', + handoffOperationId: 'handoff-op-1', + claimStatus: 'reserved', + ownerProcess: null, + reservedSpawnToken: 'spawn-restart' + }) + }) + + it('releases a proving reservation only with durable processless proof', () => { + const processless = record('new-owner-proving') + processless.lease.runtimeKind = 'native' + processless.lease.ownerProcess = null + processless.lease.processlessAt = NOW + expect( + adjudicateRestartedAgentSessionHandoff( + processless, + { outcome: 'reservation-unused' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'released', + reservedSpawnToken: null + }) + }) + + it.each([ + ['dead pid', { outcome: 'pid-absent' } as const], + [ + 'reused pid with a different start time', + { outcome: 'identity-mismatch', field: 'process-start-time' } as const + ] + ])('releases a proving owner with exact %s proof', (_name, probe) => { + const proving = record('new-owner-proving') + proving.lease.runtimeKind = 'native' + expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( + { + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null + } + ) + }) + + it('preserves the existing TUI handoff rollback after proving its target dead', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('new-owner-proving'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1' + }) + }) + + it.each([ + [ + 'live exact identity', + { outcome: 'identity-matched', matchedOn: ['process-start-time'] } as const, + 'recovering' + ], + [ + 'indeterminate identity', + { outcome: 'indeterminate', reason: 'start time unavailable' } as const, + 'recovering' + ] + ] as const)('keeps the fence and token for a %s', (_name, probe, expectedStage) => { + const proving = record('new-owner-proving') + proving.lease.runtimeKind = 'native' + expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( + { + runtimeFence: 4, + handoffStage: expectedStage, + handoffOperationId: 'handoff-op-1', + claimStatus: 'reserved', + ownerProcess: { pid: 4242, processStartTimeMs: NOW - 1_000 }, + reservedSpawnToken: 'spawn-restart' + } + ) + }) +}) diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts new file mode 100644 index 00000000000..99849248ace --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.ts @@ -0,0 +1,76 @@ +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function adjudicateRestartedAgentSessionHandoff( + record: AgentSessionRecord, + probe: AgentSessionOwnerProbe, + now: number +): AgentSessionRecord { + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe, + observedAt: now + }) + if (adjudication.disposition === 'readopt') { + return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) + } + if (adjudication.disposition === 'free') { + return updateLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + unreconciled: false, + lastRenewedAt: now + }) + } + if (adjudication.disposition !== 'evicted') { + return updateLease(record, { + ...record.lease, + handoffStage: + adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage, + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: now + }) + } + if (record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'native') { + // The attempted new owner is proven absent; no writer remains to roll back or readopt. + return updateLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) + } + const provingTuiTarget = + record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'tui' + return updateLease(record, { + ...record.lease, + runtimeKind: provingTuiTarget ? 'native' : record.lease.runtimeKind, + runtimeFence: adjudication.nextFence, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) +} + +function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts new file mode 100644 index 00000000000..93e6c4333a7 --- /dev/null +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -0,0 +1,90 @@ +/** + * Turning a restart adjudication into the next record. + * + * Applies one restart verdict to one loaded lease. Kept apart from the acquisition transitions + * because it is the only one that moves a lease WITHOUT a new owner proving anything — which is + * exactly the polarity that has to be read carefully. + */ + +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionHandoffStage, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' +import { withLease } from './agent-session-lease-transitions' + +/** Apply one restart adjudication. Never consults deadlines — only proof moves a lease. */ +export function applyAgentSessionRestartAdjudication(args: { + record: AgentSessionRecord + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + return withLease(record, { + ...record.lease, + unreconciled: false, + lastRenewedAt: args.now + }) + } + if ( + record.lease.handoffStage === 'preparing' || + record.lease.handoffStage === 'new-owner-proving' + ) { + return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now) + } + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe: args.probe, + observedAt: args.now + }) + if (adjudication.disposition === 'readopt') { + // Why: re-adoption is not a new generation, so the fence does not move. + return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) + } + if (adjudication.disposition === 'free') { + // Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing + // the stage restores it without moving the fence or touching the recorded death evidence. + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + unreconciled: false, + lastRenewedAt: args.now + }) + } + if (adjudication.disposition === 'evicted') { + return withLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: adjudication.evidence + }) + } + const stage: AgentSessionHandoffStage = + adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage + return withLease(record, { + ...record.lease, + handoffStage: stage, + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: args.now + }) +} diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts new file mode 100644 index 00000000000..b3447ec85c8 --- /dev/null +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -0,0 +1,57 @@ +import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' +import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' + +export type AgentSessionRestartProbeArgs = { + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: number +} + +type RestartProbe = { record: AgentSessionRecord; probe: AgentSessionOwnerProbe } + +export async function collectAgentSessionRestartProbes( + records: readonly AgentSessionRecord[], + args: AgentSessionRestartProbeArgs +): Promise> { + const probes = new Map() + const batched = args.probeMany ? await args.probeMany(records) : null + for (const record of records) { + probes.set(record.sessionId, { + record, + probe: + batched?.get(record.sessionId) ?? + (batched + ? { outcome: 'indeterminate', reason: 'owner batch probe returned no result' } + : await args.probe(record)) + }) + } + return probes +} + +export function applyAgentSessionRestartProbes( + state: AgentSessionStoreState, + probes: ReadonlyMap, + now: number +): Map { + const reconciled = new Map() + for (const [sessionId, probed] of probes) { + const record = state.records.get(sessionId) + if ( + !record?.lease.unreconciled || + !agentSessionReconciliationTargetMatches(record, probed.record) + ) { + continue + } + const next = applyAgentSessionRestartAdjudication({ record, probe: probed.probe, now }) + state.records.set(sessionId, next) + reconciled.set(sessionId, next) + } + state.operations = pruneAgentSessionOperationRows(state.operations, now) + return reconciled +} diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.test.ts b/src/main/runtime/agent-session-spawn-token-process-scan.test.ts new file mode 100644 index 00000000000..ba0fa2fe7c4 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-process-scan.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + findAgentSessionSpawnTokenProcesses, + scanAgentSessionSpawnTokenProcesses +} from './agent-session-spawn-token-process-scan' + +describe('agent-session spawn token process scan', () => { + it('answers null on platforms that cannot read another process environment', async () => { + // macOS and Windows have no `/proc//environ`. Answering "none" there would free a + // reservation whose child is alive and mint a second writer on the same provider session. + expect(await scanAgentSessionSpawnTokenProcesses('darwin')).toBeNull() + expect(await scanAgentSessionSpawnTokenProcesses('win32')).toBeNull() + }) + + it('propagates the host non-answer rather than reporting an empty pid list', async () => { + expect(await findAgentSessionSpawnTokenProcesses('token-1', async () => null)).toBeNull() + }) + + it('reports the pids carrying one token', async () => { + const scan = async () => new Map([['token-1', [11, 12]]]) + + expect(await findAgentSessionSpawnTokenProcesses('token-1', scan)).toEqual([11, 12]) + expect(await findAgentSessionSpawnTokenProcesses('token-2', scan)).toEqual([]) + }) +}) diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.ts b/src/main/runtime/agent-session-spawn-token-process-scan.ts new file mode 100644 index 00000000000..6db1c069110 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-process-scan.ts @@ -0,0 +1,78 @@ +/** + * Host scan for processes carrying an Orca agent-session spawn token. + * + * The token is the only PID-reuse-safe identity element a child is guaranteed to carry, and it + * lives in the child's environment — which only Linux lets another process read (`/proc//environ`). + * macOS and Windows answer `null`, meaning "this host cannot enumerate", NEVER "no process carries + * it": reporting an empty result there would free a reservation whose child is alive and hand a + * second writer to the same provider session. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' +import { spawnTokenFromEnvironBlock } from './agent-session-spawn-token-readback' + +export type AgentSessionSpawnTokenScan = ReadonlyMap + +export type AgentSessionSpawnTokenScanEvidence = + | { status: 'verified'; processes: AgentSessionSpawnTokenScan } + | { status: 'unverifiable'; processes: null; platform: NodeJS.Platform } + +/** Tokens observed on this host, or null when the platform cannot answer at all. */ +export async function scanAgentSessionSpawnTokenProcesses( + platform: NodeJS.Platform = process.platform, + variable: string = CODEX_SPAWN_TOKEN_ENV +): Promise { + if (platform !== 'linux') { + return null + } + let entries: string[] + try { + entries = await readdir('/proc') + } catch { + return null + } + const observed = new Map() + for (const entry of entries) { + const pid = Number(entry) + if (!Number.isSafeInteger(pid) || pid <= 0) { + continue + } + let token: string | null + try { + token = spawnTokenFromEnvironBlock(await readFile(`/proc/${pid}/environ`, 'utf-8'), variable) + } catch { + // A process that exited mid-scan, or one this user may not read, is not evidence either way. + continue + } + if (token === null) { + continue + } + observed.set(token, [...(observed.get(token) ?? []), pid]) + } + return observed +} + +/** + * Diagnostic evidence only. A null result is deliberately typed as + * `unverifiable`, not as an empty process set; callers must never use this + * Linux read-back as ownership or orphan-reaping proof. + */ +export async function scanAgentSessionSpawnTokenEvidence( + platform: NodeJS.Platform = process.platform, + variable: string = CODEX_SPAWN_TOKEN_ENV +): Promise { + const processes = await scanAgentSessionSpawnTokenProcesses(platform, variable) + return processes === null + ? { status: 'unverifiable', processes: null, platform } + : { status: 'verified', processes } +} + +/** Pids carrying one specific token, or null when the host could not enumerate. */ +export async function findAgentSessionSpawnTokenProcesses( + spawnToken: string, + scan: () => Promise = scanAgentSessionSpawnTokenProcesses +): Promise { + const observed = await scan() + return observed === null ? null : [...(observed.get(spawnToken) ?? [])] +} diff --git a/src/main/runtime/agent-session-spawn-token-readback.test.ts b/src/main/runtime/agent-session-spawn-token-readback.test.ts new file mode 100644 index 00000000000..d86eb0d1658 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-readback.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + readEchoedAgentSessionSpawnToken, + spawnTokenFromEnvironBlock +} from './agent-session-spawn-token-readback' + +const IDENTITY = { + hostId: 'local', + pid: process.pid, + processStartTimeMs: null, + spawnToken: 'spawn-a' +} + +describe('spawn token read-back', () => { + it('finds the token in a NUL-separated environ block', () => { + const block = [ + 'PATH=/usr/bin', + 'ORCA_AGENT_SESSION_SPAWN_TOKEN=tok-123', + 'HOME=/home/dev' + ].join('\0') + expect(spawnTokenFromEnvironBlock(block)).toBe('tok-123') + }) + + it('answers null for an absent or empty token instead of guessing', () => { + expect(spawnTokenFromEnvironBlock(['PATH=/usr/bin', 'HOME=/home/dev'].join('\0'))).toBeNull() + expect(spawnTokenFromEnvironBlock('ORCA_AGENT_SESSION_SPAWN_TOKEN=')).toBeNull() + // A prefix collision is not a match. + expect(spawnTokenFromEnvironBlock('ORCA_AGENT_SESSION_SPAWN_TOKEN_EXTRA=x')).toBeNull() + }) + + it('answers null on platforms that hide process environments', async () => { + await expect(readEchoedAgentSessionSpawnToken(IDENTITY, 'darwin')).resolves.toBeNull() + await expect(readEchoedAgentSessionSpawnToken(IDENTITY, 'win32')).resolves.toBeNull() + }) + + it('answers null when the environ file is unreadable', async () => { + await expect( + readEchoedAgentSessionSpawnToken({ ...IDENTITY, pid: 2 ** 30 }, 'linux') + ).resolves.toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-spawn-token-readback.ts b/src/main/runtime/agent-session-spawn-token-readback.ts new file mode 100644 index 00000000000..23f435fec89 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-readback.ts @@ -0,0 +1,37 @@ +/** + * Reads the spawn token a live child carries in its environment, giving the owner probe a + * PID-reuse-safe identity element even when no start time was recorded. Only Linux exposes + * another process's environment (/proc//environ); macOS and Windows answer null, which + * the probe treats as "no answer" — never as proof in either direction. + */ + +import { readFile } from 'node:fs/promises' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' + +export function spawnTokenFromEnvironBlock( + block: string, + variable: string = CODEX_SPAWN_TOKEN_ENV +): string | null { + for (const entry of block.split('\0')) { + if (entry.startsWith(`${variable}=`)) { + const value = entry.slice(variable.length + 1) + return value.length > 0 ? value : null + } + } + return null +} + +export async function readEchoedAgentSessionSpawnToken( + identity: AgentSessionProcessIdentity, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform !== 'linux') { + return null + } + try { + return spawnTokenFromEnvironBlock(await readFile(`/proc/${identity.pid}/environ`, 'utf-8')) + } catch { + return null + } +} diff --git a/src/main/runtime/agent-session-store-transaction-lock.ts b/src/main/runtime/agent-session-store-transaction-lock.ts new file mode 100644 index 00000000000..3326d563c79 --- /dev/null +++ b/src/main/runtime/agent-session-store-transaction-lock.ts @@ -0,0 +1,27 @@ +import { chmod, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { lock } from 'proper-lockfile' + +const LOCK_RETRIES = { + retries: 20, + factor: 1.3, + minTimeout: 10, + maxTimeout: 250, + randomize: true +} + +/** Serialize whole-file transactions across Orca processes sharing one execution host. */ +export async function withAgentSessionStoreTransactionLock( + filePath: string, + apply: () => Promise +): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await chmod(directory, 0o700) + const release = await lock(filePath, { realpath: false, retries: LOCK_RETRIES }) + try { + return await apply() + } finally { + await release() + } +} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts new file mode 100644 index 00000000000..43266c0f113 --- /dev/null +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -0,0 +1,169 @@ +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStoreRevision, + loadAgentSessionStore, + saveAgentSessionStore, + type AgentSessionStoreState, + type LoadedAgentSessionStore +} from './agent-session-record-store-file' +import { withAgentSessionStoreTransactionLock } from './agent-session-store-transaction-lock' + +function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { + for (const [sessionId, record] of state.records) { + state.records.set(sessionId, { + ...record, + lease: { ...record.lease, unreconciled: true } + }) + } +} + +function mapEntriesMatch(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false + } + } + return true +} + +function agentSessionStoreStateChanged( + state: AgentSessionStoreState, + records: ReadonlyMap, + operations: ReadonlyMap, + retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], + unreadableRecords: AgentSessionStoreState['unreadableRecords'] +): boolean { + return ( + !mapEntriesMatch(state.records, records) || + !mapEntriesMatch(state.operations, operations) || + !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || + state.retiredClaimKeys.length !== retiredClaimKeys.length || + state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) + ) +} + +export class AgentSessionStoreTransactionQueue { + private queue: Promise = Promise.resolve() + private diskRecoveredFromBackup: boolean + + constructor( + private readonly filePath: string, + readonly hostId: string, + readonly readOnly: boolean, + readonly recoveredFromBackup: boolean, + private diskStoreFound: boolean, + public state: AgentSessionStoreState, + private diskRevision: string, + private needsRewrite: boolean + ) { + this.diskRecoveredFromBackup = recoveredFromBackup + } + + static fromLoadedStore( + filePath: string, + hostId: string, + loaded: LoadedAgentSessionStore, + diskRevision: string + ): AgentSessionStoreTransactionQueue { + return new AgentSessionStoreTransactionQueue( + filePath, + hostId, + loaded.readOnly, + loaded.recoveredFromBackup, + loaded.storeFound, + loaded.state, + diskRevision, + loaded.needsRewrite + ) + } + + transact(apply: () => T): Promise { + const run = this.queue.then(() => + withAgentSessionStoreTransactionLock(this.filePath, async () => { + if (this.readOnly) { + throw new Error('agent_session_legacy_required') + } + await this.refreshExternallyChangedState() + const records = new Map(this.state.records) + const operations = new Map(this.state.operations) + const retiredClaimKeys = [...this.state.retiredClaimKeys] + const unreadableRecords = new Map(this.state.unreadableRecords) + try { + // The lost commit may have granted a higher fence than the backup records show. Rather + // than refuse forever, raise every recovered fence clear of anything that commit could + // have minted, then continue in the same transaction. + const recovering = this.diskRecoveredFromBackup + if (recovering) { + raiseAgentSessionFencesAfterBackupRecovery(this.state) + } + const result = apply() + if ( + !recovering && + !this.needsRewrite && + !agentSessionStoreStateChanged( + this.state, + records, + operations, + retiredClaimKeys, + unreadableRecords + ) + ) { + return result + } + await saveAgentSessionStore(this.filePath, this.state, { + primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent' + }) + this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + this.diskRevision = agentSessionStoreRevision(this.state) + this.diskRecoveredFromBackup = false + this.diskStoreFound = true + this.needsRewrite = false + return result + } catch (error) { + this.state.records = records + this.state.operations = operations + this.state.retiredClaimKeys = retiredClaimKeys + this.state.unreadableRecords = unreadableRecords + throw error + } + }) + ) + this.queue = run.catch(() => {}) + return run + } + + persistLoadedRewrite(): Promise { + return this.transact(() => undefined) + } + + private async refreshExternallyChangedState(): Promise { + const loaded = await loadAgentSessionStore(this.filePath, this.hostId) + if (this.diskStoreFound && !loaded.storeFound) { + throw new Error('agent_session_store_corrupt') + } + this.diskStoreFound ||= loaded.storeFound + const diskRevision = agentSessionStoreRevision(loaded.state) + this.diskRecoveredFromBackup = loaded.recoveredFromBackup + if (diskRevision === this.diskRevision) { + this.needsRewrite ||= loaded.needsRewrite + return + } + if (loaded.readOnly) { + throw new Error('agent_session_legacy_required') + } + markLoadedLeasesUnreconciled(loaded.state) + this.state = loaded.state + this.diskRevision = diskRevision + this.needsRewrite = loaded.needsRewrite + } +} + +export function markAgentSessionStoreLeasesUnreconciled(state: AgentSessionStoreState): void { + markLoadedLeasesUnreconciled(state) +} diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts new file mode 100644 index 00000000000..d4da43f1933 --- /dev/null +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -0,0 +1,60 @@ +// Releasing the lease when the LAST surface lets go of a session. +// +// Every other release in the wire needs a probe, because every other release is about a process +// somebody else started and nobody watched die. This one is different: the host stopped its own +// child through the adapter and the adapter proved the exit before this runs, so the evidence is +// `exit-observed` rather than an adjudicated absence. +// +// The fence still moves. A released lease at the old fence would let a mutation a client queued +// against the dead generation land on the next one. + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { assertFence, withLease } from './agent-session-lease-transitions' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +/** Whether this record is one THIS host may release on its own proof. A TUI owner, a session + * mid-handoff, and a lease nobody holds are all somebody else's transition. */ +export function isSurfaceReleasableAgentSessionRecord(record: AgentSessionRecord): boolean { + return ( + record.lease.runtimeKind === 'native' && + record.lease.claimStatus === 'live' && + record.lease.handoffStage === null && + record.lease.ownerProcess !== null + ) +} + +export function releaseAgentSessionOwnerAfterSurfaceClose(args: { + record: AgentSessionRecord + expectedFence: number + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if (!isSurfaceReleasableAgentSessionRecord(record)) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'the last surface holding this session released it', + observedAt: args.now + } + }) +} + +/** Applied through the store's generic transition, the same way handoff records move. */ +export function releaseStoredAgentSessionOwnerAfterSurfaceClose( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; now: number } +): Promise { + return store.transitionHandoff(args.sessionId, (record) => + releaseAgentSessionOwnerAfterSurfaceClose({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts new file mode 100644 index 00000000000..0d44d88d82b --- /dev/null +++ b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts @@ -0,0 +1,168 @@ +// Unreadable session records: quarantine when nothing can vouch for the session, salvage +// when the previous committed state can. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +let directory: string +let counter = 0 + +function operationId(): string { + counter += 1 + return `${NOW}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +async function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ +async function establishOwner(store: AgentSessionRecordStore): Promise { + const reserved = await store.reserveOwner(reserveRequest()) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' + }, + now: NOW + }) + return store.proveOwner({ + sessionId: 'session-alpha', + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function corruptPrimaryRecord(): Promise { + const filePath = agentSessionStorePath(directory) + const raw = JSON.parse(await readFile(filePath, 'utf-8')) + raw.records['session-alpha'].lease.runtimeFence = 'not-a-number' + await writeFile(filePath, JSON.stringify(raw)) + return filePath +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-salvage-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('unreadable session records', () => { + it('quarantines an unreadable record with no committed copy and refuses to own it', async () => { + const first = await open() + await establishOwner(first) + const filePath = await corruptPrimaryRecord() + // No previous committed state survives, so nothing can vouch for the session. + await rm(`${filePath}.bak`, { force: true }) + + const reopened = await open() + expect(reopened.getRecord('session-alpha')).toBeNull() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( + 'execution_owner_reconciling' + ) + // The row stays verbatim inside an explicit unusable envelope. + await reopened.retireClaimKey('key-2', NOW) + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records).not.toHaveProperty('session-alpha') + expect(persisted.unusableRecords['session-alpha']).toMatchObject({ + reason: 'current_shape_invalid', + raw: { lease: { runtimeFence: 'not-a-number' } } + }) + }) + + it('salvages the last committed copy of a record the primary retains as unreadable', async () => { + const first = await open() + await establishOwner(first) + // One more commit leaves the proven-owner record in the backup file. + await first.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 1 }, + now: NOW + }) + const filePath = await corruptPrimaryRecord() + + const reopened = await open() + // The previous committed state vouches for the session; its lease is re-adjudicated + // like any other, and the unreadable bytes stay quarantined verbatim. + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'live' + }) + expect(reopened.recoveredFromBackup).toBe(false) + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + expect(reopened.getRecord('session-alpha')?.lease.claimStatus).toBe('released') + + // Ownership is reachable again instead of refused with execution_owner_reconciling. + const reserved = await reopened.reserveOwner( + reserveRequest({ expectedFence: 2, spawnToken: 'spawn-b' }) + ) + expect(reserved.record.lease.claimStatus).toBe('reserved') + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records['session-alpha'].lease.claimStatus).toBe('reserved') + expect(persisted.unusableRecords['session-alpha']).toMatchObject({ + reason: 'current_shape_invalid' + }) + + // Salvage only fills gaps: with the live record readable again, a reload must never + // let the stale backup copy clobber it. + const reloaded = await open() + expect(reloaded.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'reserved', + runtimeFence: 3 + }) + }) +}) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 63957f874c0..5d0faab64f9 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -141,4 +141,10 @@ describe('mobile RPC allowlist', () => { ) ).toEqual([]) }) + + it('does not expose structured agent sessions to mobile credentials', () => { + expect( + [...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.')) + ).toEqual([]) + }) }) diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 56fa18cbb5b..9199e46783a 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -129,6 +129,26 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).not.toHaveBeenCalled() }) + it('waits for Codex shell launch preparation before a structured resume', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ + command: expect.stringContaining("'resume' 'provider-session-1'"), + startupCommandDelivery: 'shell-ready' + }) + ) + }) + it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts new file mode 100644 index 00000000000..083c677e39f --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +describe('structured agent-session create intent', () => { + it('pins the selected Codex launch home after normal launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { codex: { CODEX_HOME: '/configured/home' } } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'codex' + }) + + expect(prepareCodexStructuredLaunch).toHaveBeenCalledWith({ + workspacePath: '/repos/workspace-1', + launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) + }) + expect(intent.accountHome).toEqual({ + variable: 'CODEX_HOME', + path: '/accounts/selected/home' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts new file mode 100644 index 00000000000..39275ebedc1 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from './orca-runtime' + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('structured session cold restoration', () => { + it('skips every heavy recovery step when no durable session store exists', async () => { + const runtime = new OrcaRuntimeService() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => false + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ reconcileRestartLeases } as never) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(ensureHost).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() + expect(reconcileRestartLeases).not.toHaveBeenCalled() + }) + + it('keeps historical journal parsing outside the terminal-safety fence', async () => { + const runtime = new OrcaRuntimeService() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(ensureHost).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + expect(reconcileRestartLeases).toHaveBeenCalledOnce() + expect(restoreReadableSessions).not.toHaveBeenCalled() + }) + + it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { + const runtime = new OrcaRuntimeService() + const hydrate = vi.fn() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + worktreeId?: string, + options?: { allowAttachedWindow?: boolean; onlyRuntimeOwnedTerminals?: boolean } + ): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ + reconcileRestartLeases, + restoreReadableSessions, + listSessionTabs: () => [] + } as never) + + const first = runtime.restoreStructuredAgentSessionTabs() + const second = runtime.restoreStructuredAgentSessionTabs() + expect(second).toBe(first) + await Promise.all([first, second]) + + expect(hydrate).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(hydrate).toHaveBeenCalledWith() + expect(refresh).toHaveBeenCalledOnce() + expect(reconcileRestartLeases).toHaveBeenCalledOnce() + expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(ensureHost).toHaveBeenCalledOnce() + expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( + refresh.mock.invocationCallOrder[0] ?? Infinity + ) + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( + reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity + ) + expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( + restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity + ) + expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( + hydrate.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('normalizes a restored tab id and removes it when closed', async () => { + const runtime = new OrcaRuntimeService() + const closeSessionTab = vi.fn(async () => undefined) + runtime.setNotifier({ closeSessionTab } as never) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-session', + workspaceId: 'workspace-1', + agent: 'codex' + } + ] + } as never) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'workspace-1', + publicationEpoch: 'renderer-restored', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }], + tabs: [ + { + type: 'terminal', + id: 'terminal-tab::leaf-1', + parentTabId: 'terminal-tab', + leafId: 'leaf-1', + title: 'Terminal', + isActive: true + }, + { + type: 'terminal', + id: 'terminal-tab::leaf-2', + parentTabId: 'terminal-tab', + leafId: 'leaf-2', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + + await runtime.restoreStructuredAgentSessionTabs() + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-2' + }), + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-session', + sessionId: 'restored-session' + }) + ]) + ) + expect(restored.tabs).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:agent-session:restored-session' + }) + ]) + ) + expect(restored.tabGroups?.[0]?.tabOrder).toEqual([ + 'terminal-tab', + 'agent-session:restored-session' + ]) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:restored-session', { + reason: 'user' + }) + + expect(closeSessionTab).toHaveBeenCalledWith( + 'structured-agent-session-restored-session', + 'workspace-1' + ) + + const closed = await runtime.listMobileSessionTabs('id:workspace-1') + expect(closed.tabs.map((tab) => tab.id)).toEqual([ + 'terminal-tab::leaf-1', + 'terminal-tab::leaf-2' + ]) + expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) + }) + + it('commits the host close when the renderer already removed the structured tab', async () => { + const runtime = new OrcaRuntimeService() + runtime.setNotifier({ + closeSessionTab: vi.fn(async () => { + throw new Error('session_tab_not_found') + }) + } as never) + runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: true + }) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:session-1', { + reason: 'user' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index be6bfe922ad..7049620ab27 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1241,6 +1241,10 @@ class InMemoryOrchestrationMessages { return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) } + listWorkerTerminalReleaseBacklog(): never[] { + return [] + } + hasUndeliveredDirectMessageForRun(runId: string, directHandle: string): boolean { return this.messages.some( (message) => diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 720d04aee76..444664d60a6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -63,6 +63,7 @@ import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, + AgentSessionOwnerBinding, AgentSessionSurfaceBinding, AgentLaunchPreferences, RuntimeAgentSessionRpcCaller, @@ -81,6 +82,54 @@ import { createEphemeralAgentSessionClaimSigner, type AgentSessionClaimSigner } from './agent-session-claim-identity' +import { + ensureStructuredAgentSessionHost as installStructuredAgentSessionHost, + hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk +} from './structured-agent-session-runtime' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { + StructuredTuiLaunchCleanupError, + type StructuredAgentSessionHandoffTransport, + type StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual +} from '../../shared/agent-session-provider-handle' +import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' +import { + agentSessionOwnerBindingsEqual, + cloneAgentSessionOwnerBinding, + scopedAgentSessionClaimsEqual +} from '../../shared/claimed-agent-pty-owner-snapshot' +import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof' +import { + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from '../codex/codex-tui-rollout-proof' +import { + PROCESS_START_TIME_TOLERANCE_MS, + probeAgentSessionProcessIdentity +} from './agent-session-process-identity-probe' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' +import { + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +} from '../native-chat/session-file-resolver' +import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' +import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { + agentSessionPtyWriteGate, + type AgentSessionPtyWriteAdmittance +} from './agent-session-pty-write-gate' import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, @@ -129,8 +178,8 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' @@ -237,6 +286,8 @@ import { import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout' import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import type { OrchestrationCompatibilityEvidence, OrchestrationCompatibilityHostStamp @@ -501,6 +552,7 @@ import { type RuntimeMarkdownReadTabResult, type RuntimeMarkdownSaveTabResult, type RuntimeMobileSessionCreateTerminalResult, + type RuntimeMobileSessionAgentTab, type RuntimeMobileSessionClientTab, type RuntimeMobileSessionMarkdownTab, type RuntimeMobileSessionTabMove, @@ -598,6 +650,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { getTuiAgentLaunchCommand, @@ -1304,6 +1357,11 @@ function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): return 'Repository could not be imported' } +function isPathWithinDirectory(directory: string, candidate: string): boolean { + const relativePath = relative(resolve(directory), resolve(candidate)) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService codexAccounts: CodexAccountService @@ -1574,6 +1632,7 @@ type RuntimePtyWorktreeRecord = { // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null + agentSessionOwners: AgentSessionOwnerBinding[] foregroundAgent: TuiAgent | null connected: boolean disconnectedAt: number | null @@ -1655,6 +1714,7 @@ type TerminalCreateOptions = { // Why: only the host-derived structured resume path may attach provider // identity; opaque terminal.create commands remain ordinary shells. agentSessionClaim?: AgentSessionExecutionClaim + structuredAgentSessionId?: string agentSessionCreateOperationId?: string signal?: AbortSignal // Why: idempotent create operations must retain their fence after the PTY @@ -2024,12 +2084,18 @@ type RuntimePtyController = { } }): Promise<{ id: string + pid?: number incarnationId?: PtyIncarnationId wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean + writeAgentSessionProof?( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } + ): boolean writeWithSettlement?(ptyId: string, data: string): Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. @@ -3281,6 +3347,8 @@ export class OrcaRuntimeService { new TerminalFocusNavigationCoalescer() private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = null + private structuredAgentSessionTabRestorePromise: Promise | null = null + private structuredAgentSessionStartupRestorePromise: Promise | null = null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3803,6 +3871,12 @@ export class OrcaRuntimeService { private readonly prepareAiVaultSessionResumeFn: | ((args: AiVaultPrepareSessionResumeArgs) => Promise) | null + private readonly prepareCodexStructuredLaunchFn: + | ((input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise) + | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() private readonly orchestrationCompatibilitySshAttachments = new Map< @@ -3891,6 +3965,10 @@ export class OrcaRuntimeService { prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs ) => Promise + prepareCodexStructuredLaunch?: (input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner @@ -3951,6 +4029,7 @@ export class OrcaRuntimeService { this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null + this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null this.agentSessionClaimSigner = deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null @@ -10042,6 +10121,21 @@ export class OrcaRuntimeService { } await this.notifier.closeSessionTab(tab.id, worktreeId) } + } else if (tab.type === 'agent-session') { + if (this.notifier?.closeSessionTab) { + try { + await this.notifier.closeSessionTab( + structuredAgentSessionTabId(tab.sessionId), + worktreeId + ) + } catch (error) { + // The renderer already having removed the tab is an idempotent close, not a veto. + if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { + throw error + } + } + } + this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') @@ -10147,6 +10241,30 @@ export class OrcaRuntimeService { return true } + private closeStructuredAgentSessionTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionAgentTab + ): void { + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + private markHeadlessBrowserSessionTabActive( worktreeId: string | undefined, browserPageId: string, @@ -10773,6 +10891,25 @@ export class OrcaRuntimeService { // Why: clients reorder the sanitized session.tabs.list model; raw groups // can still contain stale browser ids hidden from paired web clients. .filter((tabId) => returnedIds.has(tabId)) + const structuredIds = expected.filter((tabId) => + snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId) + ) + if (structuredIds.some((tabId) => !seen.has(tabId))) { + if (structuredIds.some((tabId) => seen.has(tabId))) { + throw new Error('invalid_tab_order') + } + const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId)) + if ( + normalized.length !== visibleExpected.length || + visibleExpected.some((tabId) => !seen.has(tabId)) + ) { + throw new Error('invalid_tab_order') + } + for (const tabId of structuredIds) { + normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId) + } + return normalized + } // Why: reorder is a pure permutation of one existing group. Missing or // extra ids would let a paired web client silently move/lose host tabs. if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) { @@ -11167,6 +11304,1113 @@ export class OrcaRuntimeService { getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) + /** + * Installs the structured agent-session host on first use. Lazy for the same + * reason the orchestration DB is: the profile's user-data path is not final + * until the app is ready, and a runtime nobody drives a chat session on + * should never open the record store. + */ + async ensureStructuredAgentSessionHost(): Promise { + await installStructuredAgentSessionHost({ + stateDirectory: getProfileUserDataPath(), + hostId: LOCAL_EXECUTION_HOST_ID, + claimKeyId: this.agentSessionClaimSigner.keyId, + // Resolves folder workspaces as well as git worktrees, so a chat session + // in a plain folder lands in the folder rather than failing to resolve. + resolveWorkspacePath: async (workspaceId) => + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchEnvOverlay: () => + resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + handoffTransport: this.createStructuredAgentSessionHandoffTransport() + }) + } + + private resolveConfiguredCodexStructuredArgs(): string[] { + const settings = this.requireStore().getSettings() + const shell = resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + return resolveCodexStructuredAppServerArgs( + resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), + shell ?? 'posix' + ) + } + + private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: hostname(), + launchTui: async ({ record, fence, spawnToken, onSpawned }) => { + const head = record.providerHandleChain.at(-1) + if (!head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude')) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const launchStartedAt = Date.now() + const launched = await this.ensureAgentSession( + { + kind: 'explicit', + worktree: `id:${record.location.workspaceId}`, + agent: provider, + providerSession: { key: 'session_id', id: providerSessionId }, + ...(record.options ? { launchPreferences: record.options } : {}), + presentation: 'background' + }, + {}, + { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } + ) + const terminal = launched.terminal + let spawnedOwner: StructuredTuiOwner | null = null + let ptyId: string | undefined + try { + if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { + throw new Error('The resumed terminal did not publish a process identity.') + } + ptyId = terminal.ptyId + spawnedOwner = this.refreshStructuredTuiOwnerBinding({ + terminal: { + handle: terminal.handle, + tabId: terminal.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: + provider === 'codex' + ? await readCodexResumeProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + threadId: head.handle.threadId + }) + : await readStructuredTuiProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + agent: provider + }), + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + }) + await onSpawned?.(spawnedOwner) + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = + provider === 'codex' + ? await this.waitForAdoptedStructuredTuiProof({ + owner: spawnedOwner, + threadId: head.handle.threadId, + codexHome: record.accountHome.path + }) + : await this.waitForStructuredClaudeTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects'), + spawnToken, + minimumProviderSessionReceivedAt: launchStartedAt + }) + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + ...spawnedOwner, + link: + provider === 'claude' + ? claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + : spawnedOwner.link, + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: spawnedOwner.process, + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), + historySource: 'provider-resume' + }) + } catch (error) { + let closeError: unknown = null + try { + await this.closeTerminal(terminal.handle) + } catch (cleanupFailure) { + closeError = cleanupFailure + } + try { + // closeTerminal may retire the renderer handle before the PTY exit is + // observed. Prove the provider child (or, before identity publication, + // the PTY) through the same exit path used by handoff recovery. + if (spawnedOwner) { + await this.waitForStructuredTuiOwnerExit(spawnedOwner) + } else if (ptyId) { + await this.waitForStructuredTuiPtyExit(ptyId) + } else { + throw new Error('The failed terminal did not publish a PTY identity.') + } + } catch (exitFailure) { + throw new StructuredTuiLaunchCleanupError( + error, + closeError === null + ? exitFailure + : new AggregateError( + [closeError, exitFailure], + 'Structured TUI cleanup could not prove process exit.' + ) + ) + } + throw error + } + }, + waitForTuiExit: async (owner) => { + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + }, + waitForTuiIdleOrExit: async (owner, signal) => { + return this.waitForStructuredTuiIdleOrExit(owner, signal) + }, + reproveTuiOwner: async ({ record, owner }) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) + const persisted = record.lease.ownerProcess + if ( + !persisted || + persisted.hostId !== current.process.hostId || + persisted.pid !== current.process.pid || + persisted.processStartTimeMs !== current.process.processStartTimeMs || + persisted.spawnToken !== current.process.spawnToken + ) { + throw new Error('The owning terminal does not match the persisted launch identity.') + } + const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error( + `The owning ${current.link.handle.provider} child process could not be re-proved.` + ) + } + const head = record.providerHandleChain.at(-1) + const sameProviderIdentity = + head && + (current.link.handle.provider === 'claude' + ? agentSessionProviderHandleRoot(current.link.handle) === + agentSessionProviderHandleRoot(head.handle) + : (record.lease.provenHandleLinkId === null || + current.link.linkId === record.lease.provenHandleLinkId) && + agentSessionProviderHandlesEqual(current.link.handle, head.handle)) + if (!sameProviderIdentity) { + throw new Error('agent_session_identity_required') + } + if (current.link.handle.provider === 'claude' && head.handle.provider === 'claude') { + const proof = await this.waitForStructuredClaudeTuiProof({ + handle: current.terminal.handle, + paneKey: current.terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + ...current, + link: claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + } + if (current.transcriptPath || current.link.handle.provider !== 'codex') { + return current + } + if (head.handle.provider !== 'codex') { + return current + } + const threadId = head.handle.threadId + const transcriptPath = await resolvePinnedCodexRolloutProof( + record.accountHome.path, + threadId + ) + return transcriptPath ? { ...current, transcriptPath } : current + }, + recoverTuiOwner: async (record) => { + const identity = record.lease.ownerProcess + const head = record.providerHandleChain.at(-1) + if ( + !identity || + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + let candidate = [...this.ptysById.values()].find( + (pty) => + pty.connected && + pty.launchToken === identity.spawnToken && + pty.launchAgent === provider && + pty.tabId && + pty.paneKey + ) + let handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null + let durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } | undefined + if (!candidate) { + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${record.location.workspaceId}` + ) + const baseNamespace = this.getAgentSessionExecutionNamespace(workspace, provider) + if ( + !baseNamespace || + !runtimeWorktreeIdsEqual(workspace.id, record.location.workspaceId) + ) { + throw new Error('agent_session_identity_required') + } + const claim = this.agentSessionClaimSigner.createClaim({ + namespace: { ...baseNamespace, providerRoot: record.accountHome.path }, + identity: canonicalizeAgentSessionIdentity(provider, { + key: 'session_id', + id: providerSessionId + }), + canonicalWorktreeId: workspace.id + }) + const candidateEvaluations = [...this.ptysById.values()].flatMap((pty) => + pty.agentSessionOwners.map((owner) => { + const session = this.getWorkspaceSessionForWorktree(owner.surface.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, owner.surface.worktreeId) + : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (candidate) => candidate.id === owner.surface.tabId + ) + : null + const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId) + const persisted = { + sessionResolved: Boolean(session && sessionWorktreeId), + tabPresent: Boolean(persistedTab), + ptyId: + session?.terminalLayoutsByTabId[owner.surface.tabId]?.ptyIdsByLeafId?.[ + owner.surface.leafId + ] ?? null, + incarnationId: session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null + } + const evaluation = evaluateStructuredTuiRecoveryClaim( + { + expectedWorkspaceId: workspace.id, + claimMatches: scopedAgentSessionClaimsEqual(owner.claim, claim), + pty: { + connected: pty.connected, + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId + }, + owner: { + phase: owner.phase, + ptyId: owner.ptyId, + surface: owner.surface + }, + persisted + }, + runtimeWorktreeIdsEqual + ) + return { pty, owner, persisted, evaluation } + }) + ) + const recoveredCandidates = candidateEvaluations + .filter(({ evaluation }) => evaluation.matches) + .map(({ pty, owner }) => ({ pty, owner })) + const recovered = recoveredCandidates.length === 1 ? recoveredCandidates[0] : null + if (!recovered) { + console.warn('[structured-tui-recovery] claim mismatch', { + sessionId: record.sessionId, + expectedWorkspaceId: workspace.id, + persistedOwnerProcess: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs, + spawnTokenPresent: identity.spawnToken.length > 0 + }, + candidates: candidateEvaluations.map(({ pty, owner, persisted, evaluation }) => ({ + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId, + ownerSurface: owner.surface, + persisted, + mismatchedFields: evaluation.mismatchedFields + })) + }) + } + if ( + !recovered || + !(await this.proveRecoveredStructuredTuiPtyProcess(recovered.pty, identity, provider)) + ) { + throw new Error('The owning agent terminal could not be recovered.') + } + candidate = recovered.pty + candidate.tabId = recovered.owner.surface.tabId + candidate.paneKey = makePaneKey( + recovered.owner.surface.tabId, + recovered.owner.surface.leafId + ) + // Runtime handles rotate on packaged relaunch; claim, incarnation, and process proof are durable. + handle = this.issuePtyHandle(candidate) + const recoveredIncarnationId = candidate.incarnationId + if (handle && recoveredIncarnationId) { + durableOwner = { + binding: cloneAgentSessionOwnerBinding(recovered.owner), + incarnationId: recoveredIncarnationId + } + } + } + if (!candidate?.tabId || !candidate.paneKey || !handle) { + throw new Error('The owning agent terminal could not be recovered.') + } + agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) + const proof = + provider === 'codex' + ? durableOwner + ? await this.resolveRecoveredStructuredTuiTranscript({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + codexHome: record.accountHome.path, + durableOwner + }) + : await this.waitForStructuredTuiProof({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + spawnToken: identity.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + : await this.waitForStructuredClaudeTuiProof({ + handle, + paneKey: candidate.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + terminal: { + handle, + tabId: candidate.tabId, + paneKey: candidate.paneKey, + ptyId: candidate.ptyId + }, + process: identity, + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + }, + probeRecoveredOwner: async (record) => { + const identity = record.lease.ownerProcess + if (!identity) { + return 'dead' + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'identity-matched' && proof.matchedOn.length > 0) { + return 'live' + } + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return 'dead' + } + return 'unknown' + }, + stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), + tuiStatus: (owner) => this.structuredTuiStatus(owner), + closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), + revealNativeSession: ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { + if (adoptedTerminal || agent !== 'codex') { + return + } + this.publishStructuredAgentSessionTab({ + workspaceId, + sessionId, + agent, + activate: false + }) + this.notifier?.focusEditorTab?.(structuredAgentSessionTabId(sessionId), workspaceId) + }, + stopFailedTuiLaunch: async (owner) => void (await this.closeStructuredTuiOwner(owner)) + } + } + + private async proveRecoveredStructuredTuiPtyProcess( + pty: RuntimePtyWorktreeRecord, + identity: NonNullable, + provider: 'codex' | 'claude' = 'codex' + ): Promise { + const listings = await this.ptyController?.listProcesses?.(pty.connectionId) + const listed = listings?.find( + (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === pty.incarnationId + ) + if (!listed?.rootProcessId || identity.processStartTimeMs === null) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed?.rootProcessId ?? null, + mismatchedFields: [ + ...(!listed?.rootProcessId ? ['root-process-id'] : []), + ...(identity.processStartTimeMs === null ? ['persisted-process-start-time'] : []) + ] + }) + return false + } + try { + const observed = await readStructuredTuiProcessIdentity({ + hostId: identity.hostId, + rootPid: listed.rootProcessId, + spawnToken: identity.spawnToken, + agent: provider + }) + const matched = { + hostId: observed.hostId === identity.hostId, + pid: observed.pid === identity.pid, + processStartTime: + observed.processStartTimeMs !== null && + Math.abs(observed.processStartTimeMs - identity.processStartTimeMs) <= + PROCESS_START_TIME_TOLERANCE_MS + } + if (!Object.values(matched).every(Boolean)) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + persisted: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs + }, + observed: { + hostId: observed.hostId, + pid: observed.pid, + processStartTimeMs: observed.processStartTimeMs + }, + mismatchedFields: Object.entries(matched) + .filter(([, matches]) => !matches) + .map(([field]) => field) + }) + } + return Object.values(matched).every(Boolean) + } catch (error) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + mismatchedFields: [`${provider}-child-proof`], + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + private async closeStructuredTuiOwner( + owner: StructuredTuiOwner + ): Promise<{ transcriptPath?: string }> { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + const current = this.refreshStructuredTuiOwnerBinding(owner) + try { + await this.closeTerminal(current.terminal.handle) + } catch (error) { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + throw error + } + } + } + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + } + + // The new exact `codex resume ` child proves the resumed owner without + // a first turn; the pinned rollout then binds its durable transcript. + private async waitForAdoptedStructuredTuiProof(input: { + owner: StructuredTuiOwner + threadId: string + codexHome: string + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertPaneIdentity = (): void => { + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { + throw new Error('The adopted terminal lost its pane identity.') + } + } + assertPaneIdentity() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + assertPaneIdentity() + const processProof = await probeAgentSessionProcessIdentity({ identity: input.owner.process }) + if (processProof.outcome !== 'identity-matched' || processProof.matchedOn.length === 0) { + throw new Error('The resumed Codex process could not be re-proved.') + } + return { transcriptPath } + } + + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected) { + throw new Error('The owning agent terminal lost its launch identity.') + } + const handle = this.issueStructuredTuiPtyHandle(pty) + if (handle === owner.terminal.handle) { + return owner + } + return { ...owner, terminal: { ...owner.terminal, handle } } + } + + private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private async waitForStructuredTuiPtyExit(ptyId: string): Promise { + const deadline = Date.now() + 5_000 + while (this.ptysById.get(ptyId)?.connected === true) { + if (Date.now() >= deadline) { + throw new Error('terminal_handle_stale') + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) + }) + } + + private async waitForStructuredTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> { + const deadline = Date.now() + 250 + while (!signal.aborted && Date.now() < deadline) { + if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { + await this.waitForStructuredTuiOwnerExit(owner) + return 'exited' + } + if (this.structuredTuiStatus(owner) === 'idle') { + return 'idle' + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } + + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { + const identity = record.lease.ownerProcess + if (!identity) { + return + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The recovered owner process could not be stopped safely.') + } + try { + process.kill(identity.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + // SIGTERM is only a request. Escalate once, then require an independent + // absence probe before allowing the lease transition to proceed. + try { + process.kill(identity.pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const forcedDeadline = Date.now() + 5_000 + while (Date.now() < forcedDeadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('The recovered owner process did not exit after forced termination.') + } + + private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { + const pty = this.ptysById.get(owner.terminal.ptyId) + const paneKey = pty?.paneKey ?? owner.terminal.paneKey + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } + if (pty?.connected) { + const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + const blocked = detectTerminalWaitBlockedReason(text) !== null + if (!blocked && isKnownReadyPromptPreview(text)) { + return 'idle' + } + return hasStructuredTuiIdleEvidence({ + blocked, + status: pty.lastAgentStatus, + statusObservedLive: pty.lastAgentStatusObservedLive + }) + ? 'idle' + : 'busy' + } + return 'busy' + } + + private async waitForStructuredTuiProof(input: { + handle: string + paneKey: string + threadId: string + spawnToken: string + codexHome: string + sessionId: string + }): Promise<{ transcriptPath?: string; leafUuid?: never }> { + const readBoundPty = (): RuntimePtyWorktreeRecord => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.launchAgent !== 'codex' || + pty.launchToken !== input.spawnToken + ) { + throw new Error('The resumed terminal lost its launch identity.') + } + return pty + } + const initialPty = readBoundPty() + const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags, + readOutput: () => { + const pty = readBoundPty() + return { + text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + lastOutputAt: pty.lastOutputAt + } + }, + write: (data) => { + const pty = readBoundPty() + return ( + this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + ) + } + }) + } + + private async waitForStructuredClaudeTuiProof(input: { + handle: string + paneKey: string + sessionId: string + previousLeafUuid: string | null + projectsDir: string + /** Set when this call launched a new Claude process; a cached transcript marker is not enough. */ + spawnToken?: string + minimumProviderSessionReceivedAt?: number + }): Promise<{ transcriptPath: string; leafUuid: string }> { + const deadline = Date.now() + 15_000 + let incompleteTail: ClaudeTranscriptTailIncompleteError | null = null + while (Date.now() < deadline) { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if (!pty?.connected || pty.paneKey !== input.paneKey || pty.launchAgent !== 'claude') { + throw new Error('The resumed Claude terminal lost its launch identity.') + } + if (input.spawnToken) { + if (!this.hasProviderSessionObservationSource()) { + throw new Error('The Claude terminal could not prove its fresh provider session.') + } + const observedProviderRow = this.findAdoptedProviderSession( + input.paneKey, + 'claude', + input.sessionId + ) + if ( + !observedProviderRow || + observedProviderRow.launchToken !== input.spawnToken || + (input.minimumProviderSessionReceivedAt !== undefined && + observedProviderRow.receivedAt < input.minimumProviderSessionReceivedAt) + ) { + await new Promise((resolve) => setTimeout(resolve, 100)) + continue + } + } + const transcriptPath = await resolveSessionFilePath('claude', input.sessionId, { + claudeProjectsDir: input.projectsDir + }) + if (transcriptPath) { + if (!isPathWithinDirectory(input.projectsDir, transcriptPath)) { + throw new Error('The Claude terminal reported a transcript outside its account root.') + } + try { + const leafUuid = await readClaudeTranscriptLeafUuid( + transcriptPath, + input.sessionId, + input.previousLeafUuid + ) + return { transcriptPath, leafUuid } + } catch (error) { + if (!(error instanceof ClaudeTranscriptTailIncompleteError)) { + throw error + } + incompleteTail = error + } + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (incompleteTail) { + throw incompleteTail + } + throw new Error('The agent terminal did not prove the expected Claude session.') + } + + private async resolveRecoveredStructuredTuiTranscript(input: { + handle: string + paneKey: string + threadId: string + codexHome: string + durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertDurableOwner = (): void => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.incarnationId !== input.durableOwner.incarnationId || + !pty.agentSessionOwners.some((owner) => + agentSessionOwnerBindingsEqual(owner, input.durableOwner.binding) + ) + ) { + throw new Error('The resumed terminal lost its durable owner identity.') + } + } + assertDurableOwner() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + assertDurableOwner() + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + return { transcriptPath } + } + + async getStructuredAgentSessionCreateSupport( + worktreeSelector: string, + agent: 'codex' + ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { + const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) + await this.ensureStructuredAgentSessionHost() + if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { + return { supported: true } + } + return { + supported: false, + reason: + location.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'remote' + : location.wslDistro + ? 'wsl' + : 'agent' + } + } + + private hasProviderSessionObservationSource(): boolean { + return ( + this.getAgentProviderSessionRowsForPaneFn !== null || + this.getAgentProviderSessionSnapshotFn !== null + ) + } + + private findAdoptedProviderSession( + paneKey: string, + provider: 'claude' | 'codex', + providerSessionId: string + ): AgentStatusIpcPayload | undefined { + const rows = + this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? + (this.getAgentProviderSessionSnapshotFn?.() ?? []).filter((row) => row.paneKey === paneKey) + return rows + .filter((row) => row.agentType === provider && row.providerSession?.id === providerSessionId) + .reduce( + (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), + undefined + ) + } + + private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { + const target = await this.resolveRuntimeFileTarget(worktreeSelector) + const repo = this.store?.getRepo(target.worktree.repoId) + const wslDistro = + repo && !target.connectionId + ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) + : null + const folderWorkspace = this.store + ?.getFolderWorkspaces?.() + .some((workspace) => workspace.id === target.worktree.id) + return { + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree: target.worktree, + connectionId: target.connectionId + }), + wslDistro, + workspaceId: target.worktree.id, + workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) + } + } + + async resolveStructuredAgentSessionCreateIntent(input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }): Promise { + return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { + // A create has no process yet, so the current selection is what it must follow. + const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + const configuredHome = launchEnv.CODEX_HOME + return ( + preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn ? getSystemCodexHomePath() : configuredHome?.trim()) || + getSystemCodexHomePath() + ) + }) + } + + private async resolveStructuredAgentSessionIntent( + input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }, + resolveAccountHomePath: (context: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | Promise + ): Promise { + const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) + if (!support.supported) { + throw new Error('structured_agent_session_unsupported') + } + const settings = this.requireStore().getSettings() + const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) + const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + return { + envelope: { + sessionId: input.envelope.sessionId, + clientOperationId: input.envelope.clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location, + provider: input.agent, + agent: input.agent, + accountHome: { + variable: 'CODEX_HOME', + path: await resolveAccountHomePath({ workspacePath, launchEnv }) + }, + runtimeKind: 'native' + } + } + + restoreStructuredAgentSessionTabs(): Promise { + this.structuredAgentSessionTabRestorePromise ??= + this.restoreStructuredAgentSessionTabsOnce().catch((error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + }) + return this.structuredAgentSessionTabRestorePromise + } + + prepareStructuredAgentSessionStartupRestoration(): Promise { + this.structuredAgentSessionStartupRestorePromise ??= + this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { + this.structuredAgentSessionStartupRestorePromise = null + throw error + }) + return this.structuredAgentSessionStartupRestorePromise + } + + private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { + if (!this.hasPersistedStructuredAgentSessionStore()) { + return + } + // Durable agent records must exist before daemon inventory can be reconciled against them. + await this.ensureStructuredAgentSessionHost() + await this.refreshMobileSessionPtyRecords() + await getStructuredAgentSessionHost()?.reconcileRestartLeases() + } + + private hasPersistedStructuredAgentSessionStore(): boolean { + return hasPersistedStructuredAgentSessionStoreOnDisk(getProfileUserDataPath()) + } + + private async restoreStructuredAgentSessionTabsOnce(): Promise { + await this.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + await host?.restoreReadableSessions( + collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) + ) + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + for (const session of host?.listSessionTabs() ?? []) { + if (session.agent !== 'codex') { + continue + } + let sessionId = session.sessionId + while (sessionId.startsWith('agent-session:')) { + sessionId = sessionId.slice('agent-session:'.length) + } + this.publishStructuredAgentSessionTab({ + ...session, + agent: 'codex', + sessionId, + activate: false, + notify: false + }) + } + } + + publishStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'codex' + activate: boolean + notify?: boolean + }): void { + const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) + const id = `agent-session:${input.sessionId}` + if (existing?.tabs.some((tab) => tab.id === id)) { + return + } + const tab: RuntimeMobileSessionAgentTab = { + type: 'agent-session', + id, + title: 'Codex Chat', + sessionId: input.sessionId, + agent: input.agent, + isActive: input.activate + } + const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ + ...candidate, + isActive: input.activate ? false : candidate.isActive + })) + tabs.push(tab) + const priorGroups = existing?.tabGroups ?? [ + { + id: this.getHeadlessMobileSessionGroupId(input.workspaceId), + activeTabId: existing?.activeTabId ?? null, + tabOrder: [] + } + ] + const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId) + ? existing!.activeGroupId! + : priorGroups[0]!.id + const tabGroups = priorGroups.map((group) => + group.id === groupId + ? { + ...group, + activeTabId: input.activate ? id : group.activeTabId, + tabOrder: [...group.tabOrder, id] + } + : group + ) + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: input.workspaceId, + publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), + activeTabId: input.activate ? id : (existing?.activeTabId ?? null), + activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), + tabGroups, + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs + } + this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) + if (input.notify !== false) { + this.emitMobileSessionTabsSnapshot(snapshot) + } + } + private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ worktree: ResolvedWorktree repo?: Repo @@ -13358,14 +14602,20 @@ export class OrcaRuntimeService { } try { await assertTerminalInputWithinLimitWithYield(data) - await this.writeTerminalInputChunks(ptyId, data, { - // Why: a phone can claim the floor while a paste yields between chunks. - beforeWrite: () => { - if (this.getDriver(ptyId).kind === 'mobile') { - throw new Error('terminal_mobile_driver_active') + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) + await this.writeTerminalInputChunks( + ptyId, + data, + { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') + } } - } - }) + }, + admitted + ) return true } catch { return false @@ -16094,6 +17344,7 @@ export class OrcaRuntimeService { this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) + agentSessionPtyWriteGate.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -20077,13 +21328,16 @@ export class OrcaRuntimeService { return true } - private getFreshExplicitAgentStatusForHandle(handle: string): { + private getFreshExplicitAgentStatusForHandle( + handle: string, + paneKeyOverride?: string | null + ): { status: NonNullable updatedAt: number /** When this state was entered. Pinned across same-state pings, so it identifies the turn. */ stateStartedAt: number } | null { - const paneKey = this.getPaneKeyForTerminalHandle(handle) + const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 @@ -20140,12 +21394,15 @@ export class OrcaRuntimeService { signal?: AbortSignal } = {} ): Promise { + // Why: the lease is checked before the mobile floor is reserved, so a refused send never takes + // a claim it will not use. + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. const text = typeof action.text === 'string' ? action.text : '' const hasSuffix = action.enter || action.interrupt if (text) { - await this.writeTerminalInputChunks(ptyId, text, options) + await this.writeTerminalInputChunks(ptyId, text, options, admitted) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') @@ -20160,15 +21417,19 @@ export class OrcaRuntimeService { options.signal ) } + // Why: the 500ms text/suffix pause is long enough for a handoff to complete, so the submit + // is re-checked against the fence the text was admitted under. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) try { await options.beforeWrite?.(ptyId) - options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) } throw error } + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + options.reserveWrite?.(ptyId) const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') @@ -20181,6 +21442,7 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { @@ -20196,12 +21458,21 @@ export class OrcaRuntimeService { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise - } = {} + } = {}, + admitted: AgentSessionPtyWriteAdmittance ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() + let firstChunk = true while (!chunk.done) { + // Why: every inter-chunk yield is a window for a handoff to take the lease; the rest of a + // paste must not land in a session this runtime no longer owns. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { @@ -20251,6 +21522,7 @@ export class OrcaRuntimeService { this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) // Why: the floor for every wait below. Enter must never overtake bytes the execution // host is still feeding the child, and that cost is proportional to the payload. const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) @@ -20262,10 +21534,17 @@ export class OrcaRuntimeService { try { const chunks = iterateTerminalInputChunks(pastePayload) let chunk = chunks.next() + let firstChunk = true while (!chunk.done) { const nextChunk = chunks.next() assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) + // Why: the first chunk was just admitted above; re-checking the lease there would only + // re-read what `assertAdmitted` established. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false await options.beforeWrite?.(ptyId) assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -20273,6 +21552,7 @@ export class OrcaRuntimeService { permissionBaseline, this.getAgentPromptActivity(handle, ptyId) ) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) if (nextChunk.done) { renderGate?.arm() } @@ -20293,7 +21573,15 @@ export class OrcaRuntimeService { !completedPaste && this.getPtyLifecycleGeneration(ptyId) === generation ) { - this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + // Why: a lease that moved mid-paste also refuses this terminator, leaving the TUI in paste + // mode — the incoming owner re-establishes the mode, and feeding a session we no longer own + // is the worse outcome. + try { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + } catch { + // The original refusal is the actionable error. + } } renderGate?.dispose() throw error @@ -20313,6 +21601,7 @@ export class OrcaRuntimeService { } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) try { await options.beforeWrite?.(ptyId) } catch (error) { @@ -20326,6 +21615,7 @@ export class OrcaRuntimeService { const waitTextCache: AgentPromptWaitTextCache = {} const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') @@ -28739,7 +30029,8 @@ export class OrcaRuntimeService { async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {} + _caller: RuntimeAgentSessionRpcCaller = {}, + handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -28749,7 +30040,11 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const namespace = + resolvedNamespace && handoffAuthority + ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerRoot } + : resolvedNamespace if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) @@ -28783,9 +30078,17 @@ export class OrcaRuntimeService { request.agentArgs !== undefined ? request.agentArgs : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + agentEnv: { + ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + ...(handoffAuthority && request.agent === 'codex' + ? { CODEX_HOME: handoffAuthority.providerRoot } + : handoffAuthority && request.agent === 'claude' + ? { CLAUDE_CONFIG_DIR: handoffAuthority.providerRoot } + : {}) + }, ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, shell, isRemote @@ -28802,10 +30105,17 @@ export class OrcaRuntimeService { env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, + startupCommandDelivery: startup.startupCommandDelivery, presentation: request.presentation ?? 'background', tabId: request.placement?.tabId, leafId: request.placement?.leafId, agentSessionClaim: claim, + ...(handoffAuthority + ? { + launchToken: handoffAuthority.spawnToken, + structuredAgentSessionId: handoffAuthority.sessionId + } + : {}), signal: _caller.signal }) return { @@ -29294,6 +30604,9 @@ export class OrcaRuntimeService { leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (launchOpts.structuredAgentSessionId) { + agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) + } const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { // Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs @@ -29372,6 +30685,7 @@ export class OrcaRuntimeService { title: pty?.title ?? launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, + ...(result.pid ? { processId: result.pid } : {}), ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), @@ -33418,6 +34732,7 @@ export class OrcaRuntimeService { | 'isWsl' | 'wslDistro' | 'incarnationId' + | 'agentSessionOwners' > > = {} ): RuntimePtyWorktreeRecord { @@ -33448,6 +34763,7 @@ export class OrcaRuntimeService { launchToken: null, launchIncarnationId: null, launchAgent: null, + agentSessionOwners: (state.agentSessionOwners ?? []).map(cloneAgentSessionOwnerBinding), foregroundAgent: null, connected: state.connected ?? true, disconnectedAt: state.connected === false ? Date.now() : null, @@ -33493,12 +34809,22 @@ export class OrcaRuntimeService { } pty.worktreeId = worktreeId + if ( + state.incarnationId !== undefined && + pty.incarnationId !== null && + state.incarnationId !== pty.incarnationId + ) { + pty.agentSessionOwners = [] + } if (state.incarnationId !== undefined) { if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) { this.invalidatePtyIncarnationHandle(ptyId) } pty.incarnationId = state.incarnationId } + if (state.agentSessionOwners !== undefined) { + pty.agentSessionOwners = state.agentSessionOwners.map(cloneAgentSessionOwnerBinding) + } if (state.connectionId !== undefined) { pty.connectionId = state.connectionId if (state.connectionId !== null) { @@ -33785,6 +35111,7 @@ export class OrcaRuntimeService { const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [], ...(session.wslDistro !== undefined ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } : {}), @@ -33839,6 +35166,7 @@ export class OrcaRuntimeService { } pty.connected = false pty.disconnectedAt ??= Date.now() + pty.agentSessionOwners = [] // Why: this list only enumerates registered providers, so a dropped relay // clears `connected` for every one of its PTYs at once. Only `false` here // is an observed absence; `null` means no provider could be asked. @@ -34348,16 +35676,28 @@ export class OrcaRuntimeService { if (preservedTabs.length === 0) { return snapshot } + const preservedActiveTab = preservedTabs.find( + (tab) => tab.id === existing.activeTabId && tab.isActive + ) const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) const normalizedPreservedTabs = preservedTabs.map((tab) => - hasIncomingActiveTab ? { ...tab, isActive: false } : tab + hasIncomingActiveTab && !preservedActiveTab ? { ...tab, isActive: false } : tab + ) + // Why: an omitting renderer frame predates the runtime-owned structured + // publication, so it cannot revoke that publication's focus intent. + const normalizedIncomingTabs = preservedActiveTab + ? snapshot.tabs.map((tab) => (tab.isActive ? { ...tab, isActive: false } : tab)) + : snapshot.tabs + const tabs = this.mergeMobileSessionSnapshotTabs( + normalizedIncomingTabs, + normalizedPreservedTabs ) - const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) if (tabs.length === snapshot.tabs.length) { return snapshot } const activeTab = - snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? + preservedActiveTab ?? + normalizedIncomingTabs.find((tab) => tab.id === snapshot.activeTabId) ?? tabs.find((tab) => tab.id === existing.activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0] ?? @@ -34365,6 +35705,12 @@ export class OrcaRuntimeService { const terminalTabs = tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) + const tabGroups = this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ) return { ...snapshot, publicationEpoch: this.getMergedMobileSessionPublicationEpoch( @@ -34375,16 +35721,40 @@ export class OrcaRuntimeService { activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeMobileSessionTabGroups( - snapshot.worktree, - snapshot.tabGroups ?? existing.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null + tabGroups: this.mergeStructuredAgentSessionTabGroups( + tabGroups, + existing.tabGroups ?? [], + normalizedPreservedTabs, + activeTab?.id ?? null ), tabs } } + private mergeStructuredAgentSessionTabGroups( + groups: readonly RuntimeMobileSessionTabGroup[], + existingGroups: readonly RuntimeMobileSessionTabGroup[], + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTabId: string | null + ): RuntimeMobileSessionTabGroup[] { + const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session') + if (structuredTabs.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + for (const tab of structuredTabs) { + const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id + const target = next.find((group) => group.id === priorGroupId) ?? next[0] + if (target && !target.tabOrder.includes(tab.id)) { + target.tabOrder.push(tab.id) + } + if (target && tab.id === activeTabId) { + target.activeTabId = tab.id + } + } + return next + } + private buildPreservedHeadlessMobileSessionSnapshot( existing: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot | null { @@ -34455,6 +35825,9 @@ export class OrcaRuntimeService { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionSnapshotTab ): boolean { + if (tab.type === 'agent-session') { + return true + } if (tab.type === 'browser') { const liveClientPage = typeof tab.browserPageId === 'string' @@ -34860,7 +36233,7 @@ export class OrcaRuntimeService { }) continue } - if (tab.type === 'markdown' || tab.type === 'file') { + if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') { tabs.push(tab) continue } @@ -36044,8 +37417,35 @@ export class OrcaRuntimeService { this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes) } + /** Admission snapshot taken when a mailbox pointer's text lands, asserted again + * before its Enter. The two writes straddle a 500ms pause, so a structured + * session that re-leases the pty in between must not receive the submit. */ + private readonly orchestrationPointerAdmissionByPtyId = new Map< + string, + AgentSessionPtyWriteAdmittance + >() + private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise { try { + if (data === '\r') { + const admitted = this.orchestrationPointerAdmissionByPtyId.get(ptyId) + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + if (admitted) { + // Throws when the lease moved under the in-flight pointer, withholding the submit. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + } else { + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (!admission.admitted) { + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + // Preserve the controller's own refusal reporting for internal deliveries. + return this.ptyController?.write(ptyId, data) ?? false + } + this.orchestrationPointerAdmissionByPtyId.set(ptyId, { + sessionId: admission.sessionId, + runtimeFence: admission.runtimeFence + }) + } if (this.ptyController?.writeWithSettlement) { return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false) } diff --git a/src/main/runtime/orchestration-structured-chat-lease.test.ts b/src/main/runtime/orchestration-structured-chat-lease.test.ts new file mode 100644 index 00000000000..b3a78b08a5c --- /dev/null +++ b/src/main/runtime/orchestration-structured-chat-lease.test.ts @@ -0,0 +1,373 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { TERMINAL_METHODS } from './rpc/methods/terminal' + +const WORKTREE_ID = 'repo-structured-chat::/tmp/structured-chat' +const SESSION_ID = 'session-structured-chat' +const COORDINATOR = { + handle: 'term_structured_coord', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ptyId: 'pty-structured-coord' +} +const WORKER = { + handle: 'term_structured_worker', + tabId: '22222222-2222-4222-8222-222222222222', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'pty-structured-worker' +} +const PLAIN = { + handle: 'term-plain', + tabId: '33333333-3333-4333-8333-333333333333', + leafId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + ptyId: 'pty-plain' +} +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' +} + +type TestTerminal = typeof COORDINATOR + +function paneKey(terminal: TestTerminal): string { + return `${terminal.tabId}:${terminal.leafId}` +} + +function makeStore() { + const session = getDefaultWorkspaceSession() + const repo = { + id: 'repo-structured-chat', + path: '/tmp/structured-chat', + displayName: 'structured-chat', + badgeColor: '#000000', + addedAt: 0 + } + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [repo]), + getRepo: vi.fn(() => repo), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +describe('orchestration while Structured Chat owns an agent session', () => { + let directory: string + let recordStore: AgentSessionRecordStore + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let dispatcher: RpcDispatcher + let writes: Mock<(ptyId: string, data: string) => void> + let operationSequence: number + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-orchestration-structured-chat-')) + recordStore = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: WORKTREE_ID, + repoId: 'repo-structured-chat' + } as never) + writes = vi.fn<(ptyId: string, data: string) => void>() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'unused' })), + write: (ptyId: string, data: string) => { + agentSessionPtyWriteGate.assertAdmitted(ptyId) + writes(ptyId, data) + return true + }, + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => 'codex'), + listProcesses: vi.fn(async () => []), + hasPty: vi.fn(() => true) + } as never) + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.registerPty(terminal.ptyId, WORKTREE_ID, null, { + tabId: terminal.tabId, + leafId: terminal.leafId, + incarnationId: `${terminal.ptyId}-incarnation`, + agentLaunchAuthority: { launchToken: `${terminal.ptyId}-launch`, launchAgent: 'codex' } + }) + runtime.registerPreAllocatedHandleForPty(terminal.ptyId, terminal.handle) + } + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [COORDINATOR, WORKER, PLAIN].map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + title: 'Codex', + activeLeafId: terminal.leafId, + layout: null + })), + leaves: [COORDINATOR, WORKER, PLAIN].map((terminal, index) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: index + 1, + ptyId: terminal.ptyId, + paneTitle: null, + title: 'Codex' + })) + }) + await runtime.listTerminals() + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex working\x07', 1) + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex done\x07', 2) + } + operationSequence = 0 + await establishOwner('native', 'spawn-native', null) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => recordStore.getRecord(sessionId)) + agentSessionPtyWriteGate.bindPty(WORKER.ptyId, SESSION_ID) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...ORCHESTRATION_METHODS, ...TERMINAL_METHODS] + }) + }) + + afterEach(async () => { + vi.useRealTimers() + agentSessionPtyWriteGate.detachRecordLookup() + db.close() + await rm(directory, { recursive: true, force: true }) + }) + + function operation() { + operationSequence += 1 + return { + callerKey: 'structured-chat-test', + operationId: `1800000000000-${operationSequence.toString(16).padStart(32, '0')}`, + fingerprint: `structured-chat-${operationSequence}` + } + } + + async function establishOwner( + runtimeKind: 'native' | 'tui', + spawnToken: string, + expectedFence: number | null + ): Promise { + const now = 1_800_000_000_000 + operationSequence + const reserved = await recordStore.reserveOwner({ + sessionId: SESSION_ID, + location: LOCATION, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + runtimeKind, + expectedFence, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: + expectedFence === null + ? { outcome: 'indeterminate', reason: 'new session' } + : { outcome: 'pid-absent' }, + operation: operation(), + now + }) + const fence = reserved.record.lease.runtimeFence + await recordStore.commitProcessIdentity({ + sessionId: SESSION_ID, + fence, + process: { hostId: 'local', pid: 4242 + fence, processStartTimeMs: now, spawnToken }, + now + }) + await recordStore.proveOwner({ + sessionId: SESSION_ID, + fence, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: fence === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: now + }, + now + }) + } + + function createRun(coordinator = COORDINATOR) { + return db.createRun({ + objective: 'Structured Chat lease coverage', + coordinatorHandle: coordinator.handle, + coordinatorPaneKey: paneKey(coordinator) + }) + } + + function queueRunMessage(runId: string) { + return db.insertMessage({ + from: 'term_sender', + to: `run:${runId}`, + subject: 'Queued guidance', + type: 'status', + runId + }) + } + + async function rpc(method: string, params: Record, capability?: string) { + return dispatcher.dispatch({ + id: `request-${method}-${Math.random()}`, + authToken: 'test-token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `mutation-${method}-${Math.random()}`, + orchestrationCapability: capability + }) + } + + it('retains a Run mailbox pointer while Structured Chat refuses the PTY write', () => { + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('redrives the same retained pointer after the lease returns to TUI', async () => { + vi.useFakeTimers() + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + + await establishOwner('tui', 'spawn-tui', 1) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[0]?.[1]).toContain('orca orchestration check') + expect(writes.mock.calls[1]).toEqual([WORKER.ptyId, '\r']) + }) + + it('fails worker-start truthfully when its reused terminal is in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Run the queued work', runId: run.id }) + + const response = await rpc('orchestration.workerStart', { + task: task.id, + worktree: 'current', + terminal: WORKER.handle, + from: COORDINATOR.handle + }) + + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.ok).toBe(true) + expect(response.result).toMatchObject({ + state: 'failed', + failedStage: 'dispatch_input', + lastError: expect.stringMatching(/Structured Chat.*Switch it to Terminal/), + agentSessionRefusal: { code: 'agent_session_conflict', ownerRuntimeKind: 'native' } + }) + expect(db.getTask(task.id)?.status).toBe('failed') + expect(db.getDispatchContext(task.id)?.status).toBe('failed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('reports a real gate refusal with typed metadata and zero bytes written', async () => { + const response = await rpc('terminal.send', { + terminal: WORKER.handle, + text: 'new prompt', + enter: true, + agentPrompt: true, + client: { id: 'test-client', type: 'desktop' } + }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: WORKER.handle, + accepted: false, + bytesWritten: 0, + agentSessionRefusal: expect.objectContaining({ + code: 'agent_session_conflict', + ownerRuntimeKind: 'native' + }) + } + }) + expect(writes).not.toHaveBeenCalled() + }) + + it('settles worker_done while its pane remains in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Finish from Structured Chat', runId: run.id }) + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: WORKER.handle, + assigneePaneKey: paneKey(WORKER), + processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle) ?? undefined, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: paneKey(WORKER), + processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle)! + }) + + const response = await rpc( + 'orchestration.send', + { + from: WORKER.handle, + subject: 'Done', + body: 'Implemented the task. Verified the result. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + }, + capability + ) + + expect(response.ok).toBe(true) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('delivers normally to a never-adopted terminal', async () => { + vi.useFakeTimers() + const run = createRun(PLAIN) + const message = queueRunMessage(run.id) + + expect(agentSessionPtyWriteGate.admit(PLAIN.ptyId)).toEqual({ + admitted: true, + sessionId: null, + runtimeFence: null + }) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[1]).toEqual([PLAIN.ptyId, '\r']) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 2ef1c235766..7a19314ace4 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,6 +29,9 @@ import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming- export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } +// oxfmt-ignore +type DispatchCallOptions = Pick + export class RpcDispatcher { private readonly runtime: OrcaRuntimeService private readonly registry: RpcRegistry @@ -42,10 +45,7 @@ export class RpcDispatcher { this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime) } - async dispatch( - request: RpcRequest, - options?: { signal?: AbortSignal; authenticatedCallerFingerprint?: string } - ): Promise { + async dispatch(request: RpcRequest, options?: DispatchCallOptions): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -118,7 +118,11 @@ export class RpcDispatcher { return method.handler(effectiveParams, { runtime: this.runtime, signal: options?.signal, + connectionId: options?.connectionId, requestId: request.id, + clientId: options?.clientId, + clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint, diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index c46c077b768..d943775ef3b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -71,6 +71,7 @@ function makeDispatcher(): RpcDispatcher { // which delegates to the shared cache module the IPC handler also uses. const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: (args?: Parameters[0]) => listAiVaultSessions(args), resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) => @@ -82,6 +83,7 @@ function makeDispatcher(): RpcDispatcher { function makeFailingDispatcher(error: Error): RpcDispatcher { const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: vi.fn().mockRejectedValue(error) } as unknown as OrcaRuntimeService return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) @@ -195,6 +197,7 @@ describe('aiVault.prepareSessionResume', () => { const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), prepareAiVaultSessionResume } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index ce4f05b9f76..c689165924d 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -4,8 +4,14 @@ import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' +import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertLegacyAiVaultResumeAllowed, + projectStructuredAiVaultSessions +} from '../../../ai-vault/structured-session-ownership' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for @@ -56,6 +62,7 @@ export const AiVaultListSessionsParams = z export const AiVaultPrepareSessionResumeParams = z.object({ agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), executionHostId: z.string().optional() @@ -83,7 +90,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientKind, clientCapabilities }) => { + await runtime.ensureStructuredAgentSessionHost() let result try { result = await runtime.listAiVaultSessions({ @@ -101,22 +109,32 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ } // Why: web clients consume this response directly (no parent-side retag), // so sessions must come back stamped as the runtime host they addressed. - return params.executionHostId + const stamped = params.executionHostId ? restampAiVaultListResult(result, params.executionHostId) : result + return projectStructuredAiVaultSessions( + stamped, + clientKind === undefined || + (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) } }), defineMethod({ name: 'aiVault.prepareSessionResume', params: AiVaultPrepareSessionResumeParams, - handler: (params, { runtime }) => - runtime.prepareAiVaultSessionResume({ + handler: async (params, { runtime }) => { + const args: AiVaultPrepareSessionResumeArgs = { agent: params.agent, + ...(params.sessionId ? { sessionId: params.sessionId } : {}), filePath: params.filePath, codexHome: params.codexHome, // Why: the RPC executes on the transcript-owning host; never let a // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID - }) + } + await runtime.ensureStructuredAgentSessionHost() + assertLegacyAiVaultResumeAllowed(args) + return runtime.prepareAiVaultSessionResume(args) + } }) ] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index d919a62c289..1bdaf224397 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -42,6 +42,7 @@ import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' @@ -57,6 +58,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...REPO_METHODS, ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index cde2ea9a22f..6ff031ec4c1 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -5,6 +5,8 @@ import { type WorkerSetupReceipt } from './orchestration-worker-topology' import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' +import { isAgentSessionPtyWriteRefusedError } from '../../../../shared/agent-session-pty-write-admission' +import { structuredChatPtyWriteRefusalCopy } from '../../../../shared/agent-session-pty-write-refusal-copy' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -16,7 +18,13 @@ export function failWorkerStartWithReceipt(args: { setup: WorkerSetupReceipt launch: OrchestrationWorkerLaunchReceipt }): unknown { - const reason = args.error instanceof Error ? args.error.message : String(args.error) + const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error) + ? args.error.refusal + : undefined + const reason = + (agentSessionRefusal && + structuredChatPtyWriteRefusalCopy(agentSessionRefusal, 'worker-start')) ?? + (args.error instanceof Error ? args.error.message : String(args.error)) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) @@ -37,6 +45,7 @@ export function failWorkerStartWithReceipt(args: { launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], + ...(agentSessionRefusal ? { agentSessionRefusal } : {}), ...(unknown ? { nextCommands: [ diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts new file mode 100644 index 00000000000..488ab69fd1e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { SESSION_TAB_METHODS } from './session-tabs' + +const METHODS = [ + { + name: 'session.tabs.close', + runtimeMethod: 'closeMobileSessionTab', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, reason: 'user' }) + }, + { + name: 'session.tabs.closeLifecycle', + runtimeMethod: 'closeMobileSessionTab', + params: (tabId: string) => ({ + worktree: 'id:wt-1', + tabId, + reason: 'cleanup', + publicationEpoch: 'epoch-1', + terminal: 'pty-1' + }) + }, + { + name: 'session.tabs.activate', + runtimeMethod: 'activateMobileSessionTab', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, notifyClients: false }) + }, + { + name: 'session.tabs.move', + runtimeMethod: 'moveMobileSessionTab', + params: (tabId: string) => ({ + worktree: 'id:wt-1', + tabId, + targetGroupId: 'group-1', + kind: 'split', + splitDirection: 'right' + }) + }, + { + name: 'session.tabs.setTabProps', + runtimeMethod: 'setMobileSessionTabProps', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, isPinned: true }) + } +] as const + +describe('session tab structured capability mutations', () => { + for (const method of METHODS) { + it(`rejects ${method.name} when the structured row is hidden`, async () => { + const fixture = createFixture([]) + const response = await fixture.dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(false) + expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() + }) + + it(`allows ${method.name} for a capable client`, async () => { + const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) + const response = await fixture.dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(true) + expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() + }) + + it(`rejects ${method.name} for a legacy Claude row`, async () => { + const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) + const response = await fixture.dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(false) + expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() + }) + } +}) + +function createFixture(capabilities: RuntimeCapability[]) { + const snapshot = agentSnapshot() + const calls = { + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }), + activateMobileSessionTab: vi.fn().mockResolvedValue(snapshot), + moveMobileSessionTab: vi.fn().mockResolvedValue({ moved: true }), + setMobileSessionTabProps: vi.fn().mockResolvedValue({ updated: true }) + } + const runtime = { + getRuntimeId: () => 'test-runtime', + listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + ...calls + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const context: RpcDispatchStreamingOptions = { + clientKind: 'runtime', + pairedDeviceId: 'paired-client', + clientCapabilities: capabilities + } + return { + calls, + dispatch: async (method: string, params: unknown) => { + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { id: 'request-1', authToken: 'token', method, params }, + (response) => replies.push(response), + context + ) + return JSON.parse(replies[0]!) + } + } +} + +function agentSnapshot() { + const codexTab = { + type: 'agent-session' as const, + id: 'codex-session', + sessionId: 'codex-session', + title: 'Codex session', + agent: 'codex' as const, + isActive: true + } + const claudeTab = { + ...codexTab, + id: 'claude-session', + sessionId: 'claude-session', + title: 'Legacy Claude session', + agent: 'claude', + isActive: false + } + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'codex-session', + activeTabType: 'agent-session' as const, + tabGroups: [ + { + id: 'group-1', + activeTabId: 'codex-session', + tabOrder: ['codex-session', 'claude-session'] + } + ], + tabs: [codexTab, claudeTab] + } as unknown as RuntimeMobileSessionTabsResult +} diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 45f6613e354..e713f74f057 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' @@ -34,6 +37,104 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho } describe('projectSessionTabAgentStatus', () => { + it('projects structured tabs and dangling group focus out of old clients', () => { + const snapshot: RuntimeMobileSessionTabsSnapshot = { + ...makeSnapshot(false), + activeGroupId: 'group-a', + activeTabId: 'agent-session:session-a', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-a', + activeTabId: 'agent-session:session-a', + tabOrder: ['tab-1::leaf-1', 'agent-session:session-a'], + recentTabIds: ['agent-session:session-a', 'tab-1::leaf-1'] + }, + { + id: 'group-b', + activeTabId: 'agent-session:session-b', + tabOrder: ['agent-session:session-b'] + } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + }, + tabs: [ + { ...makeSnapshot(false).tabs[0]!, isActive: false }, + { + type: 'agent-session', + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-b', + title: 'Codex Chat', + sessionId: 'session-b', + agent: 'codex', + isActive: false + } + ] + } + const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', []) + expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal']) + expect(oldClient.activeTabId).toBe('tab-1::leaf-1') + expect(oldClient.activeTabType).toBe('terminal') + expect(oldClient.tabs[0]?.isActive).toBe(true) + expect(oldClient.tabGroups?.[0]?.tabOrder).toEqual(['tab-1::leaf-1']) + expect(oldClient.tabGroups).toHaveLength(1) + expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + + expect( + projectSessionTabAgentStatus(snapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + ).toEqual(oldClient) + + const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + expect(capable).toBe(snapshot) + }) + + it('withholds legacy Claude rows from paired structured clients', () => { + const snapshot = { + ...makeSnapshot(false), + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex', + title: 'Codex Chat', + sessionId: 'codex', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:claude', + title: 'Claude Chat', + sessionId: 'claude', + agent: 'claude', + isActive: false + } + ], + activeTabId: 'agent-session:codex', + activeTabType: 'agent-session' + } as unknown as RuntimeMobileSessionTabsSnapshot + + expect( + projectSessionTabAgentStatus(snapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex']) + }) + it('withholds session boundaries from legacy paired clients', () => { const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 125e083db4f..ac8cc0b2164 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,9 +1,14 @@ -import type { RuntimeCapability } from '../../../../shared/protocol-version' -import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' import type { + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot @@ -12,16 +17,24 @@ export function projectSessionTabAgentStatus true) + if (structuredVisible && clientKind !== undefined) { + projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( clientKind !== 'runtime' || clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY) ) { - return payload + return projected } let changed = false - const tabs = payload.tabs.map((tab) => { + const tabs = projected.tabs.map((tab) => { if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) { return tab } @@ -29,5 +42,73 @@ export function projectSessionTabAgentStatus( + payload: TPayload, + shouldHide: (tab: RuntimeMobileSessionAgentTab) => boolean +): TPayload { + const hiddenIds = new Set( + payload.tabs + .filter( + (tab): tab is RuntimeMobileSessionAgentTab => + tab.type === 'agent-session' && shouldHide(tab) + ) + .map((tab) => tab.id) + ) + if (hiddenIds.size === 0) { + return payload + } + const tabs = payload.tabs.filter((tab) => !hiddenIds.has(tab.id)) + const groups = payload.tabGroups + ?.map((group) => { + const tabOrder = group.tabOrder.filter((id) => !hiddenIds.has(id)) + if (tabOrder.length === 0) { + return null + } + const recentTabIds = group.recentTabIds?.filter((id) => !hiddenIds.has(id)) + return { + ...group, + activeTabId: + group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (recentTabIds?.find((id) => tabOrder.includes(id)) ?? tabOrder[0] ?? null), + tabOrder, + ...(recentTabIds ? { recentTabIds } : {}) + } + }) + .filter((group): group is NonNullable => group !== null) + const active = + tabs.find((tab) => tab.id === payload.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const validGroupIds = new Set(groups?.map((group) => group.id) ?? []) + return { + ...payload, + activeGroupId: + groups?.find((group) => group.tabOrder.includes(active?.id ?? ''))?.id ?? + groups?.[0]?.id ?? + null, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(groups ? { tabGroups: groups } : { tabGroups: undefined }), + ...(payload.tabGroupLayout !== undefined + ? { tabGroupLayout: pruneStructuredTabGroupLayout(payload.tabGroupLayout, validGroupIds) } + : {}), + tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id })) + } as TPayload +} + +function pruneStructuredTabGroupLayout( + layout: TabGroupLayoutNode | null, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout || layout.type === 'leaf') { + return layout && validGroupIds.has(layout.groupId) ? layout : null + } + const first = pruneStructuredTabGroupLayout(layout.first, validGroupIds) + const second = pruneStructuredTabGroupLayout(layout.second, validGroupIds) + return first && second ? { ...layout, first, second } : (first ?? second) } diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts index 980b63d88f4..4067836d88e 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts @@ -85,7 +85,7 @@ describe('session tab browser placement mutations', () => { it('keeps capable mutation callers on the unprojected path', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(mixedPlacementSnapshot()), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) @@ -102,7 +102,7 @@ describe('session tab browser placement mutations', () => { ) expect(response.ok).toBe(true) - expect(runtime.listMobileSessionTabs).not.toHaveBeenCalled() + expect(runtime.listMobileSessionTabs).toHaveBeenCalledOnce() expect(runtime.closeMobileSessionTab).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 11cb5f067f9..50e56144f29 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -2,23 +2,18 @@ import { withSpan } from '../../../observability/tracer' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' -import { - assertProjectedSessionTabVisible, - clientCanObserveClientHostedBrowserPages, - projectSessionTabBrowserPlacements -} from './session-tab-browser-placement-projection' +import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' +import { projectSessionTabsForClient } from './session-tabs-inventory' export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, handler: async (params, context) => { - if ( - context.clientKind && - !clientCanObserveClientHostedBrowserPages(context.clientCapabilities) - ) { - const visible = projectSessionTabBrowserPlacements( + if (context.clientKind) { + const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + context.clientKind, context.clientCapabilities ) assertProjectedSessionTabVisible(visible, params.tabId) @@ -80,8 +75,16 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.closeLifecycle', params: CloseLifecycleTab, - handler: async (params, context) => - withSpan( + handler: async (params, context) => { + if (context.clientKind) { + const visible = projectSessionTabsForClient( + await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + context.clientKind, + context.clientCapabilities + ) + assertProjectedSessionTabVisible(visible, params.tabId) + } + return withSpan( 'runtime.session-tabs.close-lifecycle', async (span) => { const result = await context.runtime.closeMobileSessionTab( @@ -117,5 +120,6 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ } } ) + } }) ] diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index cb3694489df..6b9e953e4e3 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -1,14 +1,11 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcAnyMethod } from '../core' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' import { assertProjectedSessionTabVisible, - clientCanObserveClientHostedBrowserPages, - projectSessionTabBrowserPlacements, translateProjectedSessionTabMove } from './session-tab-browser-placement-projection' +import { projectSessionTabsForClient } from './session-tabs-inventory' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ @@ -16,9 +13,10 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.activate', params: ActivateTab, handler: async (params, { runtime, clientKind, pairedDeviceId, clientCapabilities }) => { - if (clientKind && !clientCanObserveClientHostedBrowserPages(clientCapabilities)) { - const visible = projectSessionTabBrowserPlacements( + if (clientKind) { + const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, clientCapabilities ) assertProjectedSessionTabVisible(visible, params.tabId) @@ -46,9 +44,9 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ params: MoveTab, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { let translated: Parameters[2] = params - if (clientKind && !clientCanObserveClientHostedBrowserPages(clientCapabilities)) { + if (clientKind) { const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - const projected = projectSessionTabBrowserPlacements(raw, clientCapabilities) + const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities) translated = translateProjectedSessionTabMove(raw, projected, params) } const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId } @@ -115,16 +113,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ }) ] -function projectSessionTabsForMutationClient( - snapshot: RuntimeMobileSessionTabsResult, - clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] -): RuntimeMobileSessionTabsResult { - return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), - clientCapabilities - ) -} +const projectSessionTabsForMutationClient = projectSessionTabsForClient async function assertVisibleMutationTab( runtime: OrcaRuntimeService, @@ -132,13 +121,14 @@ async function assertVisibleMutationTab( tabId: string, pairedDeviceId: string | undefined, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] + clientCapabilities: Parameters[2] ): Promise { - if (!clientKind || clientCanObserveClientHostedBrowserPages(clientCapabilities)) { + if (!clientKind) { return } - const visible = projectSessionTabBrowserPlacements( + const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(worktree, pairedDeviceId), + clientKind, clientCapabilities ) assertProjectedSessionTabVisible(visible, tabId) diff --git a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts index f593e91e40d..384844141ed 100644 --- a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts @@ -13,6 +13,17 @@ function setMobileSessionSnapshot( ).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) } +function getMobileSessionSnapshot( + runtime: OrcaRuntimeService, + worktree: string +): RuntimeMobileSessionTabsSnapshot | undefined { + return ( + runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + ).mobileSessionTabsByWorktree.get(worktree) +} + function terminalTab() { return { type: 'terminal' as const, @@ -50,6 +61,154 @@ function browserTab({ } describe('session tab move validation', () => { + it('preserves a structured tab across renderer-authored snapshot sync', () => { + const runtime = new OrcaRuntimeService() + const structured = { + type: 'agent-session' as const, + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex' as const, + isActive: false + } + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'structured-epoch', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'terminal-tab', + tabOrder: ['terminal-tab', structured.id] + } + ], + tabs: [terminalTab(), structured] + }) + const incoming: RuntimeMobileSessionTabsSnapshot = { + worktree: 'wt-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }], + tabs: [terminalTab()] + } + + ;( + runtime as unknown as { + syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): Set + } + ).syncMobileSessionTabs([incoming]) + + const snapshot = getMobileSessionSnapshot(runtime, 'wt-1') + expect(snapshot?.tabs).toContainEqual(structured) + expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab', 'agent-session:session-a']) + }) + + it('publishes a structured tab into the active group instead of the first group', () => { + const runtime = new OrcaRuntimeService() + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-2', + activeTabId: 'file-tab', + activeTabType: 'file', + tabGroups: [ + { id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }, + { id: 'group-2', activeTabId: 'file-tab', tabOrder: ['file-tab'] } + ], + tabs: [ + terminalTab(), + { + type: 'file', + id: 'file-tab', + title: 'README.md', + filePath: 'README.md', + relativePath: 'README.md', + language: 'markdown', + isDirty: false, + isActive: true + } + ] + }) + + runtime.publishStructuredAgentSessionTab({ + workspaceId: 'wt-1', + sessionId: 'session-a', + agent: 'codex', + activate: true + }) + + const snapshot = getMobileSessionSnapshot(runtime, 'wt-1') + expect(snapshot?.activeGroupId).toBe('group-2') + expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) + expect(snapshot?.tabGroups?.[1]).toMatchObject({ + activeTabId: 'agent-session:session-a', + tabOrder: ['file-tab', 'agent-session:session-a'] + }) + }) + + it('preserves a capability-hidden structured tab during an old-client reorder', async () => { + const runtime = new OrcaRuntimeService() + const moveSessionTab = vi.fn() + runtime.setNotifier({ moveSessionTab } as never) + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'terminal-tab', + tabOrder: ['terminal-tab', 'agent-session:session-a', 'file-tab'] + } + ], + tabs: [ + terminalTab(), + { + type: 'agent-session', + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex', + isActive: false + }, + { + type: 'file', + id: 'file-tab', + title: 'README.md', + filePath: 'README.md', + relativePath: 'README.md', + language: 'markdown', + isDirty: false, + isActive: false + } + ] + }) + + await runtime.moveMobileSessionTab('id:wt-1', { + kind: 'reorder', + tabId: 'file-tab', + targetGroupId: 'group-1', + tabOrder: ['file-tab', 'terminal-tab'] + }) + + expect(moveSessionTab).toHaveBeenCalledWith('wt-1', { + kind: 'reorder', + tabId: 'file-tab', + targetGroupId: 'group-1', + tabOrder: ['file-tab', 'agent-session:session-a', 'terminal-tab'] + }) + }) + it('validates reorder moves against sanitized visible tab groups', async () => { const runtime = new OrcaRuntimeService() const moveSessionTab = vi.fn() diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 5b9958aeee8..3a322e33ed7 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -14,30 +14,42 @@ import { } from './session-tabs-inventory' import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' +import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => - projectSessionTabsForClient( + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { + await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, clientCapabilities ) + } }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, context) => listSessionTabsInventory(context) + handler: async (_params, context) => { + await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + return listSessionTabsInventory(context) + } }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => - runtime.createMobileSessionTerminal(params.worktree, { + handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => { + if (params.command) { + await assertLegacyAiVaultResumeCommandAllowed(params.command, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + return runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, @@ -63,6 +75,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ // of running down the timeout and rolling back a live tab (#7718). signal }) + } }), defineStreamingMethod({ name: 'session.tabs.subscribe', @@ -76,6 +89,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ let unsubscribe = (): void => {} let closed = false let initialized = false + await restoreStructuredTabsIfSupported(runtime, clientCapabilities) const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return @@ -142,7 +156,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, context, emit) => subscribeSessionTabsInventory(context, emit) + handler: async (_params, context, emit) => { + await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + return subscribeSessionTabsInventory(context, emit) + } }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts new file mode 100644 index 00000000000..2dd317e08b0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -0,0 +1,58 @@ +// Who may see `agentSession.*` at all. +// +// Shared by every structured method file so one gate governs the whole surface: a client that does +// not advertise `agent-session.structured.v1` is told the surface does not exist rather than being +// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST +// by calling into it, which is an observable side effect. + +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import type { RpcContext } from '../core' + +/** + * In-process callers are the same build as the host, so they carry no negotiated + * capability list; every remote client must say it can read structured sessions. + */ +export function supportsStructuredSessions(ctx: RpcContext): boolean { + return ( + ctx.clientKind === undefined || + (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) +} + +export function requireStructuredCapability(ctx: RpcContext): void { + if (!supportsStructuredSessions(ctx)) { + throw new Error('structured_agent_session_unsupported') + } +} + +export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { + requireStructuredCapability(ctx) + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + return host +} + +/** Attach is the only way a session comes into being, so it is the only call + * that builds the host. Every other method addresses a session that must + * already be attached, and correctly reports absent when none is. */ +export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { + // Gated first: a client that cannot read structured sessions must not be able + // to make the host exist, which is an observable side effect of the surface. + if (!supportsStructuredSessions(ctx) || getStructuredAgentSessionHost()) { + return + } + await ctx.runtime.ensureStructuredAgentSessionHost() +} + +/** Mirrors the existing agent-session host-authority derivation so one client + * gets one operation namespace across both surfaces. */ +export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { + return { + callerKey: ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts new file mode 100644 index 00000000000..61bb3849bc7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -0,0 +1,235 @@ +// The wire half of a session's lifetime: who takes a hold, and what happens when they vanish. +// +// Run against the REAL subscription registry rather than a stub, because the backstop being tested +// IS that registry's connection sweep — a stubbed `registerSubscriptionCleanup` would prove that +// the handler called a function, which is not the claim. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CONNECTION = 'connection-1' +const GRACE_MS = 5 +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: CONNECTION +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let runtime: OrcaRuntimeService +let dispatcher: RpcDispatcher +let closeSession: Mock> +let requests = 0 + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + requests += 1 + await dispatcher.dispatchStreaming( + { id: `request-${requests}`, authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + return replies[0] as RpcResponse +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) + resetHostTestOperationIds() + requests = 0 + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + releaseGraceMs: GRACE_MS, + now: () => NOW + }) + setStructuredAgentSessionHost(host) + runtime = new OrcaRuntimeService() + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a client that holds a session', () => { + it('keeps the provider child while the hold stands', async () => { + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true }) + + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('releases it when the client says so', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + expect( + await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not report success when no provider child can be acquired', async () => { + const response = await call('agentSession.hold', { + sessionId: 'session-missing', + holderId: 'chat-missing' + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_identity_required' } + }) + expect(host.isHeld('session-missing')).toBe(false) + }) +}) + +describe('a client that disappears without cleanup', () => { + it('still releases the session when its transport closes', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not release a hold another connection is still holding', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + await dispatcher.dispatchStreaming( + { + id: 'request-other', + authToken: 'token', + method: 'agentSession.hold', + params: { sessionId: SESSION, holderId: 'chat-1' } + }, + () => {}, + { ...CLIENT, clientId: 'device-2', connectionId: 'connection-2' } + ) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('does not let an old connection sweep release its same-document replacement', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + await dispatcher.dispatchStreaming( + { + id: 'request-replacement', + authToken: 'token', + method: 'agentSession.hold', + params: { sessionId: SESSION, holderId: 'chat-1' } + }, + () => {}, + { ...CLIENT, connectionId: 'connection-2' } + ) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + + runtime.cleanupSubscriptionsForConnection('connection-2') + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + }) + + it('releases a desktop subscription when its renderer transport dies', async () => { + const transport = new AbortController() + await dispatcher.dispatchStreaming( + { + id: 'desktop-subscription', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + { + signal: transport.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + clientCapabilities: CLIENT.clientCapabilities + } + ) + expect(host.isHeld(SESSION)).toBe(true) + + transport.abort() + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not let a stream alone resume a released session', async () => { + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + await host.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + + await dispatcher.dispatchStreaming( + { + id: 'request-subscribe', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + CLIENT + ) + + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts new file mode 100644 index 00000000000..346082bd576 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -0,0 +1,64 @@ +// `agentSession.hold` / `agentSession.release` — a surface saying it is bound to a session. +// +// The holder identity is scoped to the CONNECTION, not taken from the client verbatim: two clients +// are free to name their surfaces the same thing, and a hold that collides is one that a stranger +// can release. +// +// The registered cleanup is the backstop, and the ONLY thing that covers a client which vanishes — +// a paired client that disconnects mid-turn never gets to send its release. Registering it before taking +// the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the +// stale release lands before this hold rather than after it. + +import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredHost +} from './structured-agent-session-gate' +import { HoldParams } from './structured-agent-session-schemas' + +const HOLD_CLEANUP_PREFIX = 'agentSession.hold' + +function holderKeyFor(ctx: RpcContext, holderId: string): string { + const client = ctx.clientId?.trim() || (ctx.clientKind ?? 'runtime') + return `${ctx.connectionId ?? 'local'}:${client}:${holderId}` +} + +function holdCleanupIdFor(sessionId: string, holderKey: string): string { + return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` +} + +export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.hold', + params: HoldParams, + handler: async (params, ctx) => { + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredHost(ctx) + const holderKey = holderKeyFor(ctx, params.holderId) + ctx.runtime.registerSubscriptionCleanup( + holdCleanupIdFor(params.sessionId, holderKey), + () => host.release(params.sessionId, holderKey), + ctx.connectionId + ) + try { + await host.hold(params.sessionId, holderKey) + } catch (error) { + ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + throw error + } + return { held: true as const } + } + }), + defineMethod({ + name: 'agentSession.release', + params: HoldParams, + handler: async (params, ctx) => { + const host = requireStructuredHost(ctx) + const holderKey = holderKeyFor(ctx, params.holderId) + host.release(params.sessionId, holderKey) + // Retires the backstop too; its release is a no-op against a holder already gone. + ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + return { released: true as const } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts new file mode 100644 index 00000000000..6a9372ed2c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -0,0 +1,203 @@ +// Wire validation for `agentSession.*`. +// +// Strict objects throughout: zod drops unknown keys, and a silently dropped key +// is how a newer client's field becomes a different effect on an older host. + +import { z } from 'zod' +import { isAgentSessionId } from '../../../../shared/agent-session-record' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../../../../shared/agent-session-wire' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' + +const MAX_ID_LENGTH = 512 +const MAX_PROMPT_BYTES = 256 * 1024 +const MAX_BLOCKS = 64 +const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +const Identifier = (message: string) => + z + .string() + .min(1, message) + .max(MAX_ID_LENGTH, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict() +]) + +const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: z.literal('codex') + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + +export const CreateSupportParams = z + .object({ + worktree: Identifier('Invalid worktree selector'), + agent: z.literal('codex') + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + retryUnknown: z.literal(true).optional(), + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id') }) + .strict() + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id') + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const OptionsParams = z.object({ sessionId: SessionId }).strict() + +/** One surface's claim on one session. The id names the surface, not the client: two chat views + * looking at the same session are two holders, and either leaving must not release + * the other's. */ +export const HoldParams = z + .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() + +/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts new file mode 100644 index 00000000000..4da598ff876 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -0,0 +1,428 @@ +// The wire boundary: who may see `agentSession.*` at all, and what shapes it +// accepts once they can. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ALL_RPC_METHODS } from './index' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' + +const SESSION = 'session-alpha' +const FINGERPRINT = 'f'.repeat(64) +const OPERATION = '1800000000000-00000000000000000000000000000001' + +function envelope(overrides: Record = {}) { + return { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: 1, + payloadFingerprint: FINGERPRINT, + ...overrides + } +} + +function sendParams(overrides: Record = {}) { + return { + envelope: envelope(), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...overrides + } +} + +function attachParams(overrides: Record = {}) { + return { + envelope: envelope({ expectedRuntimeFence: null }), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + ...overrides + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +let hostCalls: Record> +let runtimeCalls: Record> + +function hostStub(): StructuredAgentSessionHost { + hostCalls = { + attach: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + sessionId: SESSION, + fence: 1, + page: { + sessionId: SESSION, + epoch: 'epoch-a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-a', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), + readOptions: vi.fn(async () => ({ + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + current: { model: 'gpt-live' } + })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + return hostCalls as unknown as StructuredAgentSessionHost +} + +function dispatcher(): RpcDispatcher { + runtimeCalls = { + getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + runtimeKind: 'native' + })), + publishStructuredAgentSessionTab: vi.fn() + } + const runtime = { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn(), + ...runtimeCalls + } + return new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +} + +/** The reply path is the only one that carries a client's negotiated identity, + * which is exactly what the capability gate reads. */ +async function call( + method: string, + params: unknown, + client?: { + clientId?: string + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: string[] + } +): Promise { + const replies: RpcResponse[] = [] + await dispatcher().dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +const STRUCTURED_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +beforeEach(() => { + setStructuredAgentSessionHost(hostStub()) +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('capability gating', () => { + it('advertises the capability without bumping the protocol version', () => { + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) + // Additive methods do not break an old client; bumping would strand every + // paired device that has not updated. + expect(RUNTIME_PROTOCOL_VERSION).toBe(3) + }) + + it('registers every structured method on the runtime manifest', () => { + const names = new Set(ALL_RPC_METHODS.map((method) => method.name)) + for (const method of STRUCTURED_AGENT_SESSION_METHODS) { + expect(names).toContain(method.name) + } + // Bump deliberately: the whole agentSession.* surface is behind the structured capability, + // so an additive method is invisible to old clients and needs no protocol bump. + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16) + }) + + it('hides the surface from a declared client that did not advertise it', async () => { + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: ['terminal.stream.v1'] + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('rejects create intent before resolving host-owned fields for an old client', async () => { + const worktree = 'id:workspace-1' + const response = await call( + 'agentSession.create', + { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled() + }) + + it('serves a client that advertised it', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + + it('serves an in-process caller, which negotiates no capabilities at all', async () => { + const response = await call('agentSession.send', sendParams()) + expect(response).toMatchObject({ ok: true }) + }) + + it('reports the surface as absent when no host is installed', async () => { + setStructuredAgentSessionHost(null) + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false }) + }) +}) + +describe('method routing', () => { + it('creates from a client intent while the host resolves paths and provider identity', async () => { + const worktree = 'id:workspace-1' + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + const created = await call('agentSession.create', params, STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' } + }) + ) + expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: SESSION, activate: true }) + ) + }) + + it('separates create from ensure by the fence the client may declare', async () => { + const created = await call('agentSession.create', attachParams()) + expect(created).toMatchObject({ ok: true }) + + const fenced = await call('agentSession.create', attachParams({ envelope: envelope() })) + expect(fenced).toMatchObject({ ok: false }) + + const ensured = await call('agentSession.ensure', attachParams({ envelope: envelope() })) + expect(ensured).toMatchObject({ ok: true }) + }) + + it('tags the prompt kind from the method name, not from the client', async () => { + const params = { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'allow' + } + await call('agentSession.respondToApproval', params, STRUCTURED_CLIENT) + await call('agentSession.respondToQuestion', params, STRUCTURED_CLIENT) + expect(hostCalls.respondToPrompt.mock.calls.map((invocation) => invocation[1].kind)).toEqual([ + 'approval', + 'question' + ]) + }) + + it('does not register the structured handoff mutation', async () => { + const response = await call('agentSession.requestHandoff', { + envelope: envelope(), + direction: 'to-tui', + mode: 'now', + action: 'start' + }) + + expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + }) +}) + +describe('parameter validation', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('rejects an unknown key rather than dropping it', async () => { + await rejects('agentSession.send', { ...sendParams(), replyToItemId: 'item-1' }) + await rejects('agentSession.send', { + ...sendParams(), + envelope: { ...envelope(), priority: 'high' } + }) + }) + + it('refuses to let a client author anything but a user turn', async () => { + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] } + }) + ) + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'Bash' }] } + }) + ) + }) + + it('rejects a journal-only opaque provider handle', async () => { + await rejects( + 'agentSession.create', + attachParams({ providerHandle: { kind: 'opaque', agent: 'codex', value: 'thread-1' } }) + ) + }) + + it('rejects Claude structured create shapes', async () => { + await rejects('agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'claude' + }) + const fields = { worktree: 'id:workspace-1', agent: 'claude' } + await rejects('agentSession.create', { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields + }) + }), + ...fields + }) + }) + + it('requires a sha256 fingerprint and a positive fence', async () => { + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'f' }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'F'.repeat(64) }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ expectedRuntimeFence: 0 }) }) + ) + }) + + it('requires the item revision on a prompt answer', async () => { + await rejects('agentSession.respondToApproval', { + envelope: envelope(), + itemId: 'item-1', + optionId: 'allow' + }) + }) + + it('bounds a history page and validates its cursor', async () => { + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 100_000 + }) + await rejects('agentSession.history', { sessionId: SESSION, direction: 'sideways' }) + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: -1 } + }) + }) + + it('accepts a well-formed history request', async () => { + const response = await call( + 'agentSession.history', + { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: 4 }, + limit: 40 + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts new file mode 100644 index 00000000000..0f007006109 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -0,0 +1,246 @@ +// `agentSession.*` — the structured session RPC surface. +// +// Every method here is gated on the client advertising +// `agent-session.structured.v1`. A client that does not is told the surface does +// not exist rather than being handed a session it cannot render or drive; that +// is the whole visibility rule, because nothing else on the runtime publishes a +// structured session. + +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../../shared/agent-session-mutation-envelope' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + ensureStructuredHostInstalled as ensureHostInstalled, + requireStructuredCapability, + requireStructuredHost as requireHost, + structuredCallerFor as callerFor, + supportsStructuredSessions +} from './structured-agent-session-gate' +import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' +import { + AttachParams, + CancelParams, + CreateParams, + CreateSupportParams, + HistoryParams, + HandoffStatusParams, + OptionsParams, + RespondParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-schemas' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { + const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` + // Shared control multiplexes several streams over one socket; the frame id + // keeps one subscriber from evicting another on the same session. + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.createSupport', + params: CreateSupportParams, + handler: async (params, ctx) => { + if (!supportsStructuredSessions(ctx)) { + throw new Error('structured_agent_session_unsupported') + } + return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) + } + }), + defineMethod({ + name: 'agentSession.create', + params: CreateParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + if (params.envelope.expectedRuntimeFence !== null) { + throw new Error('agent_session_operation_invalid') + } + if ('worktree' in params) { + const intentFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { worktree: params.worktree, agent: params.agent } + }) + const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params) + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: { + location: resolved.location, + provider: resolved.provider, + agent: resolved.agent, + accountHome: resolved.accountHome, + runtimeKind: resolved.runtimeKind, + expectedRuntimeFence: null + } + }) + await ensureHostInstalled(ctx) + const result = await requireHost(ctx).attach(callerFor(ctx), { + ...resolved, + envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } + }) + if (result.ok && resolved.agent === 'codex') { + ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: resolved.location.workspaceId, + sessionId: result.value.sessionId, + agent: 'codex', + activate: true + }) + } + return result + } + await ensureHostInstalled(ctx) + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.ensure', + params: AttachParams, + handler: async (params, ctx) => { + await ensureHostInstalled(ctx) + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.send', + params: SendParams, + handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.cancel', + params: CancelParams, + handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + }), + defineMethod({ + // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the + // same session can be attached again. Only the provider child and the in-memory entry go. + name: 'agentSession.close', + params: OptionsParams, + handler: async (params, ctx) => { + await requireHost(ctx).close(params.sessionId) + return { ok: true as const } + } + }), + defineMethod({ + name: 'agentSession.respondToApproval', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + }), + defineMethod({ + name: 'agentSession.setOption', + params: SetOptionParams, + handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + }), + defineMethod({ + name: 'agentSession.options', + params: OptionsParams, + handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + }), + defineMethod({ + name: 'agentSession.history', + params: HistoryParams, + handler: async (params, ctx) => requireHost(ctx).history(params) + }), + defineStreamingMethod({ + name: 'agentSession.subscribe', + params: SubscribeParams, + handler: async (params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = subscriptionIdFor(ctx, params.sessionId) + // A live stream is a surface too: it keeps a session from being evicted while it is read and + // releases that retention when the transport dies without a word. + // + // Retain-only: reading history must never be what starts a provider process. Current clients + // explicitly hold every open surface before subscribing. + const streamHolder = `subscription:${subscriptionId}` + let closed = false + let dispose = (): void => {} + let releaseTransportSubscription = (): void => {} + const onTransportAbort = (): void => releaseTransportSubscription() + const cleanup = () => { + closed = true + ctx.signal?.removeEventListener('abort', onTransportAbort) + dispose() + host.release(params.sessionId, streamHolder) + } + let registration: { releaseIfCurrent: () => void } + if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { + registration = ctx.runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + cleanup, + ctx.connectionId + ) + } else { + ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) + registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } + } + releaseTransportSubscription = registration.releaseIfCurrent + ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) + if (ctx.signal?.aborted) { + onTransportAbort() + } + if (closed) { + return + } + // The host emits the opening snapshot (or the missed batch) synchronously + // inside open(), so nothing between here and there can interleave. + dispose = host.subscribe({ + id: subscriptionId, + sessionId: params.sessionId, + emit, + ...(params.cursor ? { cursor: params.cursor } : {}) + }) + if (closed) { + dispose() + } else { + // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a + // readable session, which is exactly what the client sees anyway. + void host + .hold(params.sessionId, streamHolder, { resume: false }) + .catch((error: unknown) => + console.warn('[agent-session] stream hold failed', params.sessionId, error) + ) + } + } + }), + defineMethod({ + name: 'agentSession.unsubscribe', + params: UnsubscribeParams, + handler: async (params, ctx) => { + requireHost(ctx) + const connection = ctx.connectionId ?? 'local' + const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + if (params.subscriptionId) { + ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) + return { unsubscribed: true } + } + ctx.runtime.cleanupSubscription(base) + ctx.runtime.cleanupSubscriptionsByPrefix(`${base}:`) + return { unsubscribed: true } + } + }), + ...STRUCTURED_AGENT_SESSION_HOLD_METHODS +] diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts new file mode 100644 index 00000000000..c1f265cc4c8 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -0,0 +1,11 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +export async function restoreStructuredTabsIfSupported( + runtime: RpcContext['runtime'], + capabilities: readonly string[] | undefined +): Promise { + if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + await runtime.restoreStructuredAgentSessionTabs() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index 7313eb82b94..8c234ce2ba4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -1,3 +1,4 @@ +import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { InvalidArgumentError } from '../../core' import type { DriverState, @@ -91,6 +92,11 @@ export function watchSubscriptionLifetime( } export function isTerminalStreamInputRejection(error: unknown): boolean { + // Why: a lease refusal is a deliberate rejection, not a transport failure, so the stream reports + // it through the WriteUnavailable frame old clients already decode rather than a new opcode. + if (isAgentSessionPtyWriteRefusedError(error)) { + return true + } const message = error instanceof Error ? error.message : String(error) return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 9baa4827a55..6bafeb93966 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,3 +1,5 @@ +import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' @@ -21,6 +23,16 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime, clientId, signal }) => { await assertTerminalSendTextWithinLimit(params.text) await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + if (params.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + if (params.resolvedLaunchDraft?.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.resolvedLaunchDraft.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -188,6 +200,18 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ ) } catch (error) { mobileFloorClaim.current?.rollback() + if (isAgentSessionPtyWriteRefusedError(error)) { + // Why: name the owner and the stage instead of a bare not-writable, so a client can say + // who holds the session rather than retrying into a lease it will never win. + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + agentSessionRefusal: error.refusal + } + } + } const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { diff --git a/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts b/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts new file mode 100644 index 00000000000..94c6731f384 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + AgentSessionPtyWriteRefusedError, + type AgentSessionPtyWriteRefusal +} from '../../../shared/agent-session-pty-write-admission' + +// terminal.send is the one write path a paired client can reach, so a lease refusal has to arrive +// as a result it can render — not as a transport error and not as a silent `accepted: false`. + +const REFUSAL: AgentSessionPtyWriteRefusal = { + code: 'agent_session_conflict', + sessionId: 'session-alpha-1', + ownerRuntimeKind: 'native', + handoffStage: 'preparing', + ownerPid: 4242, + runtimeFence: 7 +} + +const rollback = vi.fn() + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + beginMobileInputFloor: vi.fn(() => ({ commit: async () => {}, rollback })), + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'terminal.send', params } +} + +async function send(runtime: OrcaRuntimeService, client: { id: string; type: string }) { + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + return await dispatcher.dispatch(makeRequest({ terminal: 'terminal-1', text: 'hello', client })) +} + +describe('terminal.send under a refusing lease', () => { + it('returns the typed refusal instead of failing the call', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0, + agentSessionRefusal: REFUSAL + } + }) + }) + + it('keeps the refusal additive so an old client still reads accepted: false', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result as { send: { accepted: boolean; refusedReason?: string } } + expect(result.send.accepted).toBe(false) + // A new `refusedReason` value would reach old clients as an unknown enum member. + expect(result.send.refusedReason).toBeUndefined() + }) + + it('releases the mobile input floor a refused send never used', async () => { + rollback.mockClear() + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + throw new AgentSessionPtyWriteRefusedError(REFUSAL) + }) + }) + + await send(runtime, { id: 'mobile-1', type: 'mobile' }) + + expect(rollback).toHaveBeenCalled() + }) + + it('still surfaces unrelated send failures as errors', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8ca08e042b3..b508f734c04 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -13,6 +13,7 @@ import { type RuntimeMetadataOwnershipWatch } from './runtime-metadata-ownership-watch' import { RpcDispatcher } from './rpc/dispatcher' +import { ALL_RPC_METHODS } from './rpc/methods' import type { RpcAnyMethod, RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-mutation-executor' @@ -597,7 +598,7 @@ export class OrcaRuntimeRpcServer { methods }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime - this.dispatcher = new RpcDispatcher({ runtime, methods }) + this.dispatcher = new RpcDispatcher({ runtime, methods: methods ?? ALL_RPC_METHODS }) this.userDataPath = userDataPath this.pid = pid this.platform = platform diff --git a/src/main/runtime/saved-structured-agent-session-restoration.test.ts b/src/main/runtime/saved-structured-agent-session-restoration.test.ts new file mode 100644 index 00000000000..1386e5a97f0 --- /dev/null +++ b/src/main/runtime/saved-structured-agent-session-restoration.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' + +function tab(input: Partial & Pick): Tab { + const { id, ...overrides } = input + return { + id, + entityId: input.entityId ?? id, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function session(tabs: Tab[], activeTabId: string | null): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { 'workspace-1': tabs }, + activeTabIdByWorktree: { 'workspace-1': activeTabId } + } +} + +describe('saved structured session restoration targets', () => { + it('prioritizes the visible chat and excludes closed history', () => { + const saved = session( + [ + tab({ id: 'tab-background', structuredSessionId: 'session-background' }), + tab({ id: 'tab-visible', structuredSessionId: 'session-visible' }) + ], + 'tab-visible' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual([ + 'session-visible', + 'session-background' + ]) + expect(collectSavedStructuredAgentSessionIds(session([], null))).toEqual([]) + }) + + it('keeps restoration on the local execution host and deduplicates adopted tabs', () => { + const saved = session( + [ + tab({ id: 'remote', executionHostId: 'ssh:build', structuredSessionId: 'session-remote' }), + tab({ id: 'local-a', structuredSessionId: 'session-local' }), + tab({ + id: 'local-b', + contentType: 'terminal', + structuredSessionId: 'session-local' + }) + ], + 'remote' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual(['session-local']) + }) + + it('skips explicitly Claude-owned structured tabs', () => { + const saved = session( + [ + tab({ + id: 'claude-tab', + agentSessionAgent: 'claude', + structuredSessionId: 'session-claude' + }), + tab({ + id: 'codex-tab', + agentSessionAgent: 'codex', + structuredSessionId: 'session-codex' + }) + ], + 'claude-tab' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual(['session-codex']) + }) +}) diff --git a/src/main/runtime/saved-structured-agent-session-restoration.ts b/src/main/runtime/saved-structured-agent-session-restoration.ts new file mode 100644 index 00000000000..819e38df67e --- /dev/null +++ b/src/main/runtime/saved-structured-agent-session-restoration.ts @@ -0,0 +1,43 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +function savedSessionId(tab: Tab): string | null { + if (tab.executionHostId && tab.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return null + } + if (tab.agentSessionAgent === 'claude') { + return null + } + return tab.structuredSessionId ?? (tab.contentType === 'agent-session' ? tab.entityId : null) +} + +/** Visible chats restore first; closed historical journals stay lazy. */ +export function collectSavedStructuredAgentSessionIds( + session: WorkspaceSessionState | null +): string[] { + const tabs = Object.values(session?.unifiedTabs ?? {}).flat() + const activeTabIds = new Set( + Object.values(session?.activeTabIdByWorktree ?? {}).filter( + (tabId): tabId is string => typeof tabId === 'string' + ) + ) + const selected: string[] = [] + const seen = new Set() + const add = (tab: Tab): void => { + const sessionId = savedSessionId(tab) + if (sessionId && !seen.has(sessionId)) { + seen.add(sessionId) + selected.push(sessionId) + } + } + for (const tab of tabs) { + if (activeTabIds.has(tab.id)) { + add(tab) + } + } + for (const tab of tabs) { + add(tab) + } + return selected +} diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts new file mode 100644 index 00000000000..c00fdf2cbee --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -0,0 +1,891 @@ +// One structured Codex session driven end to end over `agentSession.*`. +// +// Nothing here is stubbed except the Codex child itself: the RPC dispatcher, the +// zod schemas, the capability gate, the durable record store, the journal, the +// lease, the Codex adapter, and the event-to-journal translation are all the ones +// that ship. The fake app-server answers the same JSON-RPC calls the real one +// does and pushes the same notifications and blocking requests back. + +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../shared/agent-session-wire' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' +import { readJournalBlob } from '../native-chat/agent-session-journal/journal-blob-store' +import { appendLegacyTranscriptMessages } from '../native-chat/agent-session-journal/journal-legacy-import' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../native-chat/agent-session-journal/journal-store' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'session-integration-1' +const THREAD = 'thread-integration' +const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +const CLIENT = { + clientId: 'device-a', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +// ─── the fake `codex app-server` ──────────────────────────────────────────── + +type CodexScript = { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +// `closed` is readonly on the real connection; the fake flips it so the test can +// see the takeover reap the previous child. +type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +function fakeCodex(): CodexScript { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + const live = (): FakeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +// ─── the RPC client ───────────────────────────────────────────────────────── + +let operations = 0 + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real + * time, not a frozen constant: the runtime under test stamps the ledger with + * its own clock and refuses a future-dated id. */ +function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function attachParams(fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +let codex: CodexScript +let root: string +let dispatcher: RpcDispatcher +let bootEnvironmentReads: number +let codexOverrideReads: number +let configuredCodexProfile: string + +/** Runs a one-shot method and returns its decoded reply. */ +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) + return result.value as T +} + +/** Opens a live subscription and keeps collecting frames after the call settles. */ +async function subscribe( + requestId: string, + cursor?: { epoch: string; sequence: number } +): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: requestId, + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION, ...(cursor ? { cursor } : {}) } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +/** Settles everything the provider streamed into the journal. Real clients see + * these rows arrive on the subscription; a test has to wait for them. */ +function drainStreamedEvents(): Promise { + return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() +} + +function textOf(item: AgentJournalRenderItem): string { + const body = item.body + return body?.kind === 'message' + ? body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +async function historyPage( + direction: 'tail' | 'before' | 'after', + extra: Record = {} +): Promise { + const response = await call('agentSession.history', { + sessionId: SESSION, + direction, + ...extra + }) + return (response as { result: AgentSessionHistoryResult }).result +} + +beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + codex = fakeCodex() + bootEnvironmentReads = 0 + codexOverrideReads = 0 + configuredCodexProfile = 'configured' + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveEnvironment: async () => { + bootEnvironmentReads += 1 + return { + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + } + }, + resolveCodexOverrides: () => { + codexOverrideReads += 1 + return { CODEX_PROFILE: configuredCodexProfile } + }, + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { + return { + releaseIfCurrent: dispose + } + }) + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured codex session over agentSession.*', () => { + it('hydrates provider options after activating a legacy-imported journal', async () => { + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const journal = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: THREAD, + fence: 0, + messages: [ + { + id: 'legacy-user-1', + role: 'user', + source: 'transcript', + timestamp: 1_800_000_000_000, + blocks: [{ type: 'text', text: 'legacy question' }] + } + ] + }) + + const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items.map(textOf)).toContain('legacy question') + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + }) + + it('dispatches and streams a plain first send from a fresh session', async () => { + const created = await ok<{ fence: number; page: { items: unknown[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items).toEqual([]) + expect(codex.live().launch.env).toMatchObject({ + CODEX_PROFILE: 'configured', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + }) + const store = await readFile(join(root, 'agent-sessions', 'agent-sessions.json'), 'utf-8') + expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') + expect(store).not.toContain('"launchEnv"') + const stream = await subscribe('sub-first-send') + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + + const sent = await ok<{ + clientMessageId: string + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/start', + params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } + }) + + codex.notify('turn/started', { turn: { id: TURN } }) + codex.notify('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + }) + codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'Hello.' } + }) + await drainStreamedEvents() + + expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + }) + + it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { + // ── create ────────────────────────────────────────────────────────────── + // No host exists yet; `create` is the call that builds one. + expect(getStructuredAgentSessionHost()).toBeNull() + const created = await ok<{ fence: number; page: { items: unknown[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items).toEqual([]) + expect(codex.live().calls[0]).toMatchObject({ + method: 'thread/start', + params: { cwd: `/repos/${WORKSPACE}` } + }) + const fence = created.fence + + const stream = await subscribe('sub-1') + expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) + + // ── options ───────────────────────────────────────────────────────────── + const options = await call('agentSession.options', { sessionId: SESSION }) + expect(options).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + await ok('agentSession.setOption', { + envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), + key: 'model', + value: 'gpt-live' + }) + await ok('agentSession.setOption', { + envelope: envelope('agentSession.setOption', { key: 'effort', value: 'high' }, fence), + key: 'effort', + value: 'high' + }) + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-live', effort: 'high' } } + }) + expect(itemsOf(stream)).toEqual([]) + + // ── send ──────────────────────────────────────────────────────────────── + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'list files' }] } + const sent = await ok<{ + clientMessageId: string + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, fence), + body + }) + // Codex named the turn, so the submission is accepted rather than + // "delivery unconfirmed", and adopts the provider's own item identity. + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/start', + params: { + threadId: THREAD, + clientUserMessageId: sent.clientMessageId, + model: 'gpt-live', + effort: 'high' + } + }) + + // ── stream ────────────────────────────────────────────────────────────── + codex.notify('turn/started', { turn: { id: TURN } }) + // Codex echoes the user message back as ordinal 0 of the turn. That is the + // key the submission adopted, so the echo has to reconcile into the bubble + // the client already has rather than append a second copy of it. + codex.notify('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list files' }] } + }) + await drainStreamedEvents() + expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) + + codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'files.' }) + await drainStreamedEvents() + // The 60ms window has not elapsed, so no half-written row reached the + // journal — the coalescer is holding both deltas. + expect(itemsOf(stream).filter((item) => textOf(item).startsWith('Two'))).toEqual([]) + + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'Two files.' } + }) + await drainStreamedEvents() + const answer = itemsOf(stream).find((item) => textOf(item) === 'Two files.') + // Keyed by (threadId, turnId, ordinal), so a resumed thread reuses this row. + expect(answer?.itemId).toBe(`codex:${THREAD}:${TURN}:1`) + + // ── approval ──────────────────────────────────────────────────────────── + codex.notify('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls -la', status: 'inProgress' } + }) + codex.ask(7, 'item/commandExecution/requestApproval', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-2', + availableDecisions: ['accept', 'decline'] + }) + await drainStreamedEvents() + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Run a command?', detail: 'ls -la' }) + + const answered = await ok<{ resolution: { state: string; selectedOptionId: string } }>( + 'agentSession.respondToApproval', + { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }, + fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + } + ) + expect(answered.resolution).toMatchObject({ state: 'resolved', selectedOptionId: 'accept' }) + // The durable journal item id round-tripped back to the live Codex request. + expect(codex.live().replies).toEqual([{ id: 7, result: { decision: 'accept' } }]) + + // ── cancel ────────────────────────────────────────────────────────────── + const cancelled = await ok<{ turnId: string; cancelled: boolean }>('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: TURN }, fence), + turnId: TURN + }) + expect(cancelled).toEqual({ turnId: TURN, cancelled: true }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/interrupt', + params: { threadId: THREAD, turnId: TURN } + }) + + // ── reconnect ─────────────────────────────────────────────────────────── + // The client drops. Its subscription is reaped; the session and its child + // are not. + await call('agentSession.unsubscribe', { sessionId: SESSION }) + const lastCursor = cursorOf(stream) + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-3', text: 'Stopped.' } + }) + await drainStreamedEvents() + + // Resubscribing from the cursor it held replays only what it missed. + const missed = await subscribe('sub-2', lastCursor) + expect(missed[0]?.type).toBe('batch') + expect(itemsOf(missed).map(textOf)).toEqual(['Stopped.']) + + // A runtime taking the session over is the other half of reconnect: the + // fence advances, the old child is reaped, and its replacement resumes the + // thread this session proved rather than forking a new one. + const reaped = codex.live() + const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( + 'agentSession.ensure', + attachParams(fence) + ) + expect(resumed.fence).toBe(fence + 1) + expect(reaped.closed).toBe(true) + expect(codex.live().resumedThreadId).toBe(THREAD) + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live', effort: 'high' } + } + }) + // The journal belongs to the session, not to the process that just died. + expect(resumed.page.items.map(textOf)).toContain('Two files.') + + // ── page history ──────────────────────────────────────────────────────── + const tail = await historyPage('tail', { limit: 2 }) + expect(tail.ok).toBe(true) + if (!tail.ok) { + throw new Error('history reset') + } + expect(tail.page.hasOlder).toBe(true) + const older = await historyPage('before', { + cursor: tail.page.window.nextCursor, + limit: 10 + }) + if (!older.ok) { + throw new Error('history reset') + } + expect(older.page.hasOlder).toBe(false) + // Every step of the conversation, in order, from the durable journal alone — + // no page overlaps another, and nothing the live stream showed is missing. + expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ + 'message', + 'message', + 'tool-call', + 'approval', + 'status', + 'message' + ]) + expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ + 'list files', + 'Two files.', + '', + '', + '', + 'Stopped.' + ]) + }) + + it('caches shell exports but re-reads configured overrides for a resume', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ + bootEnvironmentReads: 1, + codexOverrideReads: 1 + }) + + configuredCodexProfile = 'updated' + const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) + + expect(resumed.fence).toBe(created.fence + 1) + expect(codex.live().resumedThreadId).toBe(THREAD) + expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated' }) + expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ + bootEnvironmentReads: 1, + codexOverrideReads: 2 + }) + }) + + it('refuses to build a host for a client that never advertised the capability', async () => { + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { + id: 'req-gate', + authToken: 'token', + method: 'agentSession.create', + params: attachParams(null) + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: ['terminal.stream.v1'] } + ) + + expect(replies[0]).toMatchObject({ ok: false }) + // Building the host is itself observable — it opens a store and spawns a + // child — so the gate has to run before it, not after. + expect(getStructuredAgentSessionHost()).toBeNull() + expect(codex.connections).toEqual([]) + }) + + it('joins final deferred writes before runtime teardown completes', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { type: 'agentMessage', id: 'item-final', text: '' } + }) + await drainStreamedEvents() + + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-final', + delta: 'Final text before shutdown.' + }) + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const appendEntered = Promise.withResolvers() + const appendGate = Promise.withResolvers() + const originalAppend = journal.appendItem.bind(journal) + vi.spyOn(journal, 'appendItem').mockImplementationOnce(async (...args) => { + appendEntered.resolve() + await appendGate.promise + return originalAppend(...args) + }) + + let stopped = false + const stopping = stopStructuredAgentSessionRuntime().then(() => { + stopped = true + }) + await appendEntered.promise + await new Promise((resolve) => setImmediate(resolve)) + const waitedForFinalAppend = !stopped + appendGate.resolve() + await stopping + + expect(waitedForFinalAppend).toBe(true) + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const reopened = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') + expect( + reopened + .snapshot() + .items.some( + (item) => item.body?.kind === 'status' && item.body.turnLifecycle?.state === 'running' + ) + ).toBe(false) + }) + + it('persists truncated command output before publishing its journal row', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const output = 'large command output\n'.repeat(2_000) + + codex.notify('item/completed', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-large-output', + command: 'print-many-lines', + status: 'completed', + exitCode: 0, + aggregatedOutput: output + } + }) + await drainStreamedEvents() + + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') + const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined + expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) + expect(await readJournalBlob(journal.directory, bounded?.digest ?? '')).toBe(output) + }) + + it('replays a durable image send without dispatching it twice', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const path = '/tmp/orca-paste-image.png' + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path }] + } + const params = { + envelope: envelope('agentSession.send', { body }, created.fence), + body + } + + await ok('agentSession.send', params) + const replay = await call('agentSession.send', params) + + expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) + expect(codex.live().calls.filter((entry) => entry.method === 'turn/start')).toHaveLength(1) + }) + + it('joins an acquired attach through journal bind before draining final rows', async () => { + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps + .adapter + const historyEntered = Promise.withResolvers() + const historyGate = Promise.withResolvers() + const originalHistoryFilePath = adapter.historyFilePath.bind(adapter) + vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => { + historyEntered.resolve() + await historyGate.promise + return originalHistoryFilePath(input) + }) + + const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + await historyEntered.promise + codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { type: 'agentMessage', id: 'item-bind-window', text: '' } + }) + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-bind-window', + delta: 'Buffered while the journal opens.' + }) + + let stopped = false + const stopping = stopStructuredAgentSessionRuntime().then(() => { + stopped = true + }) + await new Promise((resolve) => setImmediate(resolve)) + const waitedForJournalBind = !stopped + historyGate.resolve() + await creating + await stopping + expect(waitedForJournalBind).toBe(true) + + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const reopened = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') + expect( + reopened + .snapshot() + .items.some( + (item) => item.body?.kind === 'status' && item.body.turnLifecycle?.state === 'running' + ) + ).toBe(false) + }) +}) + +/** Every item the subscription has published, latest revision per id. */ +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const published = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.page.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const item of published) { + items.set(item.itemId, item) + } + } + return [...items.values()] +} + +function cursorOf(frames: AgentSessionSubscribeEvent[]): { epoch: string; sequence: number } { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index] as AgentSessionSubscribeEvent + if (frame.type === 'batch') { + return frame.batch.cursor + } + if (frame.type === 'snapshot' || frame.type === 'reset') { + return frame.page.liveCursor ?? frame.page.window.nextCursor + } + } + throw new Error('subscription published no cursor') +} diff --git a/src/main/runtime/structured-agent-session-pty-binding.test.ts b/src/main/runtime/structured-agent-session-pty-binding.test.ts new file mode 100644 index 00000000000..e6656c025a8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pty-binding.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const SESSION_ID = 'structured-session-1' + +afterEach(() => agentSessionPtyWriteGate.detachRecordLookup()) + +describe('structured handoff PTY binding', () => { + it('binds before runtime writes and unbinds on process exit', async () => { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const write = vi.fn(() => true) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })), + write, + kill: () => true, + getForegroundProcess: async () => null + }) + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION_ID, + runtimeKind: 'native', + handoffStage: 'new-owner-proving' + }) + ) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => + sessionId === SESSION_ID ? record : null + ) + + await runtime.createTerminal('id:worktree-1', { + command: 'codex resume thread-1', + structuredAgentSessionId: SESSION_ID + }) + + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID) + await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false) + expect(write).not.toHaveBeenCalled() + + runtime.onPtyExit('pty-structured', 0) + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts new file mode 100644 index 00000000000..6adf5d368fd --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionClaimStatus, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { + createStructuredAgentSessionOwnerProbe, + createStructuredAgentSessionOwnerProbes, + ensureStructuredAgentSessionHost, + hasPersistedStructuredAgentSessionStore, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const HOST_ID = 'local' + +function record( + ownerProcess: AgentSessionProcessIdentity | null, + lease: { + processlessAt?: number | null + reservedSpawnToken?: string | null + claimStatus?: AgentSessionClaimStatus + runtimeFence?: number + } = {} +): AgentSessionRecord { + return { + sessionId: 'session-1', + providerHandleChain: [], + lease: { + ownerProcess, + reservedSpawnToken: null, + claimStatus: 'released', + runtimeFence: 3, + ...lease + } + } as unknown as AgentSessionRecord +} + +const OWNER: AgentSessionProcessIdentity = { + hostId: HOST_ID, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'token-1' +} + +const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) + +describe('structured agent-session store presence', () => { + it('stops after finding the durable primary store', () => { + const fileExists = vi.fn(() => true) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) + expect(fileExists).toHaveBeenCalledOnce() + expect(fileExists).toHaveBeenCalledWith( + join('/profile', 'agent-sessions', 'agent-sessions.json') + ) + }) + + it('checks the durable backup when the primary store is absent', () => { + const fileExists = vi.fn((path: string) => path.endsWith('.bak')) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) + expect(fileExists).toHaveBeenNthCalledWith( + 1, + join('/profile', 'agent-sessions', 'agent-sessions.json') + ) + expect(fileExists).toHaveBeenNthCalledWith( + 2, + join('/profile', 'agent-sessions', 'agent-sessions.json.bak') + ) + }) + + it('reports a fresh profile absent after two bounded presence checks', () => { + const fileExists = vi.fn(() => false) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) + expect(fileExists).toHaveBeenCalledTimes(2) + }) +}) + +describe('structured agent-session owner probe', () => { + it('probes an owner this host spawned', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe)(record(OWNER)) + + expect(probe).toHaveBeenCalledWith({ + identity: OWNER, + deps: { readEchoedSpawnToken: expect.any(Function) } + }) + expect(result).toEqual({ outcome: 'pid-absent' }) + }) + + it('reads the process table once for many local owners', async () => { + const secondOwner = { ...OWNER, pid: 5252, spawnToken: 'token-2' } + const probeMany = vi.fn(async () => [ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'pid-absent' as const } + ]) + const probeOne = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'unused' })) + const records = [ + record(OWNER), + { ...record(secondOwner), sessionId: 'session-2' } + ] as AgentSessionRecord[] + + const results = await createStructuredAgentSessionOwnerProbes( + HOST_ID, + probeMany, + probeOne + )(records) + + expect(probeMany).toHaveBeenCalledOnce() + expect(probeMany).toHaveBeenCalledWith({ + identities: [OWNER, secondOwner], + deps: { readEchoedSpawnToken: expect.any(Function) } + }) + expect(probeOne).not.toHaveBeenCalled() + expect(results.get('session-1')?.outcome).toBe('identity-matched') + expect(results.get('session-2')).toEqual({ outcome: 'pid-absent' }) + }) + + it('refuses to probe an owner on another host, whose pid means nothing here', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + probe + )(record({ ...OWNER, hostId: 'ssh:build-box' })) + + expect(probe).not.toHaveBeenCalled() + expect(result.outcome).toBe('indeterminate') + }) + + it('leaves a reservation whose spawn token is still live on this host latched', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe, async () => [9001])( + record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' }) + ) + + // Evicting here would put a second writer on a live Codex thread. + expect(result.outcome).toBe('indeterminate') + }) + + it('leaves a reservation latched on a host that cannot enumerate spawn tokens', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => null + )(record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' })) + + expect(result.outcome).toBe('indeterminate') + }) + + it('frees a reservation once the host proves no process carries its token', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => [] + )(record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' })) + + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) + + it('frees a lease that names neither an owner nor a spawn token', async () => { + const probe = deadProbe() + const scan = vi.fn(async () => [] as number[]) + // Nothing was ever minted that a child could be carrying, so no scan is even needed; + // answering `indeterminate` here is what latches every released record into recovery. + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe, scan)(record(null)) + + expect(probe).not.toHaveBeenCalled() + expect(scan).not.toHaveBeenCalled() + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) + + it('still refuses a reservation that recorded no token to scan for', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => [] + )(record(null, { claimStatus: 'reserved' })) + + expect(result.outcome).toBe('indeterminate') + }) + + it('releases only a reservation carrying durable pre-spawn proof', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + probe + )(record(null, { processlessAt: 1_800_000_000_000, claimStatus: 'reserved' })) + + expect(probe).not.toHaveBeenCalled() + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) +}) + +describe('structured agent-session runtime install', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + vi.restoreAllMocks() + }) + + it('starts orphan reaping and reports failures without failing installation', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const failure = new Error('scan failed') + const reapOrphanChildren = vi.fn(async () => { + throw failure + }) + const onError = vi.fn() + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + reapOrphanChildren, + onError + }) + ).resolves.toBeDefined() + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith({ + scope: 'agent-session-orphan-child-reaper', + error: failure + }) + ) + expect(reapOrphanChildren).toHaveBeenCalledWith({ store: expect.anything() }) + }) + + it('logs an orphan-reaper failure when no reporter is configured', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const failure = new Error('scan failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + reapOrphanChildren: async () => { + throw failure + } + }) + ).resolves.toBeDefined() + + await vi.waitFor(() => + expect(consoleError).toHaveBeenCalledWith( + '[structured-agent-session] orphan reaper failed', + failure + ) + ) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts new file mode 100644 index 00000000000..2226fd35b6e --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -0,0 +1,278 @@ +// Where the structured agent-session wire becomes a live host on this runtime. +// +// Built on the first `agentSession.*` call rather than at startup: the record +// store and the journals live under the profile's user-data path, which is not +// final until Electron is ready, and a runtime that never serves a structured +// session should not pay for a store it will never read. The slot the RPC layer +// reads is module-level for the same reason the registry is — the runtime +// service is already far past its size budget. + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { + CodexStructuredSessionAdapter, + type CodexStructuredSessionAdapterDeps +} from '../codex/codex-structured-session-adapter' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' +import { + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation +} from './agent-session-process-identity-probe' +import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' +import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +/** Sibling of the journal tree rather than inside it: one file adjudicates every + * session's lease, while a journal is per session. */ +const RECORD_STORE_DIR_NAME = 'agent-sessions' + +export function hasPersistedStructuredAgentSessionStore( + stateDirectory: string, + fileExists: (path: string) => boolean = existsSync +): boolean { + const filePath = agentSessionStorePath(join(stateDirectory, RECORD_STORE_DIR_NAME)) + return fileExists(filePath) || fileExists(`${filePath}.bak`) +} + +export type StructuredAgentSessionRuntimeDeps = { + /** Host state root. The record store and the journal tree both hang off it. */ + stateDirectory: string + /** Execution host this runtime *is*. A record pinned elsewhere is not ours to + * probe and not ours to spawn for. */ + hostId: string + /** Key id this host's claims are minted under. */ + claimKeyId: string + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + /** Provider transports are overridden only to drive the runtime against scripted children. */ + openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ + readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] + resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] + resolveLaunchEnv?: () => Promise + resolveLaunchEnvOverlay?: () => Promise> | Record + resolveEnvironment?: () => Promise + resolveCodexOverrides?: () => NodeJS.ProcessEnv + onError?: (input: { scope: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport + reapOrphanChildren?: typeof stopOrphanAgentSessionChildren +} + +type InstalledRuntime = { + host: StructuredAgentSessionHost + adapter: CodexStructuredSessionAdapter +} + +let installing: Promise | null = null + +export function ensureStructuredAgentSessionHost( + deps: StructuredAgentSessionRuntimeDeps +): Promise { + // A failed open must not poison the slot forever — the next call retries. + installing ??= install(deps).catch((error) => { + installing = null + throw error + }) + return installing.then((installed) => installed.host) +} + +/** Drops the host and reaps every Codex child under it. Runtime teardown and + * test isolation take the same path, so neither can leave a live app-server. */ +export async function stopStructuredAgentSessionRuntime(): Promise { + const pending = installing + installing = null + setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.detachRecordLookup() + if (!pending) { + return + } + const installed = await pending.catch(() => null) + if (!installed) { + return + } + try { + await installed.adapter.closeAll() + } finally { + await installed.host.flushAllStreamedEvents() + } +} + +async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() + const resolveEnvironment = async (): Promise => ({ + ...(await bootEnvironment), + ...(await deps.resolveLaunchEnv?.()), + ...(await deps.resolveLaunchEnvOverlay?.()), + ...deps.resolveCodexOverrides?.() + }) + const store = await AgentSessionRecordStore.open({ + directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), + hostId: deps.hostId + }) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + // Why: only the durable store can identify a provider child lost before record publication. + void (deps.reapOrphanChildren ?? stopOrphanAgentSessionChildren)({ store }).catch((error) => { + try { + if (deps.onError) { + deps.onError({ scope: 'agent-session-orphan-child-reaper', error }) + } else { + console.error('[structured-agent-session] orphan reaper failed', error) + } + } catch (reportingError) { + console.error( + '[structured-agent-session] orphan reaper error reporting failed', + reportingError + ) + } + }) + try { + const codex = new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) + const adapter = codex + const host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: deps.stateDirectory, + claimKeyId: deps.claimKeyId, + probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), + probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), + ...(deps.resolveLaunchArgs + ? { + resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => + await deps.resolveLaunchArgs!(provider) + } + : {}), + onEventSinkError: ({ sessionId, error }) => + deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + persistTuiProviderHandle: async ({ sessionId, link, now }) => { + await store.transitionHandoff(sessionId, (record) => + recordAgentSessionProviderHandle({ record, fence: record.lease.runtimeFence, link, now }) + ) + }, + ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}) + }) + setStructuredAgentSessionHost(host) + return { host, adapter } + } catch (error) { + agentSessionPtyWriteGate.detachRecordLookup() + throw error + } +} + +/** + * The lease's only source of truth about a previous owner. Everything it cannot + * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ +export function createStructuredAgentSessionOwnerProbe( + hostId: string, + probe = probeAgentSessionProcessIdentity, + findSpawnTokenProcesses = findAgentSessionSpawnTokenProcesses +): (record: AgentSessionRecord) => Promise { + return async (record) => { + const owner = record.lease.ownerProcess + if (!owner) { + if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { + return { outcome: 'reservation-unused' } + } + const spawnToken = record.lease.reservedSpawnToken + if (spawnToken === null) { + if (record.lease.claimStatus === 'reserved') { + return { + outcome: 'indeterminate', + reason: 'reservation recorded no spawn token to scan for' + } + } + // The token is minted before the child and is the only thing a child could be carrying. + // No owner and no token means nothing on any host can be holding this lease — answering + // `indeterminate` here is what latches an already-free record into recovery forever. + return { outcome: 'reservation-unused' } + } + // Freeing a reservation needs positive proof that nothing spawned under its token. The scan + // answers null where the platform cannot read another process's environment. + return probeAgentSessionReservation({ + spawnToken, + findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), + hasProviderActivitySinceReservation: async () => + agentSessionReservationTouchedProvider(record) + }) + } + if (owner.hostId !== hostId) { + // Checking a remote host's pid against this machine's process table is + // exactly how a live owner gets declared dead. + return { + outcome: 'indeterminate', + reason: `owner runs on ${owner.hostId}, which this host cannot probe` + } + } + // The env read-back answers on hosts that expose it and null elsewhere, giving the + // probe a PID-reuse-safe element even when no start time was recorded. + return probe({ + identity: owner, + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + } +} + +export function createStructuredAgentSessionOwnerProbes( + hostId: string, + probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, + probeOne = createStructuredAgentSessionOwnerProbe(hostId) +): (records: readonly AgentSessionRecord[]) => Promise> { + return async (records) => { + const results = new Map() + const localOwners: { + record: AgentSessionRecord + owner: NonNullable + }[] = [] + for (const record of records) { + const owner = record.lease.ownerProcess + if (owner?.hostId === hostId) { + localOwners.push({ record, owner }) + } else { + results.set(record.sessionId, await probeOne(record)) + } + } + const probes = await probeMany({ + identities: localOwners.map(({ owner }) => owner), + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + for (const [index, { record }] of localOwners.entries()) { + results.set( + record.sessionId, + probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } + ) + } + return results + } +} + +/** + * The only provider-side trace a reservation can leave in its own record: a handle link minted at + * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a + * link at the reservation's fence means a child got far enough to resume the provider thread. It + * cannot see activity the child produced without proving a handle, which is why it is paired with + * the token scan rather than trusted alone. + */ +function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { + return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence +} diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts new file mode 100644 index 00000000000..3652aae1880 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' + +const identity: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 123, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-token' +} + +describe('structured TUI exit proof', () => { + it('accepts the exact terminal exit event without probing', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe }) + ).resolves.toBeUndefined() + expect(probe).not.toHaveBeenCalled() + }) + + it.each([ + { outcome: 'pid-absent' as const }, + { outcome: 'identity-mismatch' as const, field: 'process-start-time' as const } + ])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof + }) + ).resolves.toBeUndefined() + }) + + it.each([ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'indeterminate' as const, reason: 'probe unavailable' } + ])('fails closed while the recorded process may still own: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof, + staleHandleProbeAttempts: 1 + }) + ).rejects.toThrow('terminal_handle_stale') + }) + + it('retries the persisted process identity when handle retirement wins the exit race', async () => { + const probe = vi + .fn() + .mockResolvedValueOnce({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + }) + .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe, + staleHandleProbeAttempts: 2, + staleHandleProbeIntervalMs: 0 + }) + ).resolves.toBeUndefined() + expect(probe).toHaveBeenNthCalledWith(1, identity) + expect(probe).toHaveBeenNthCalledWith(2, identity) + }) + + it('preserves unrelated terminal wait failures', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('timeout') + }, + probe + }) + ).rejects.toThrow('timeout') + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts new file mode 100644 index 00000000000..c9c733f4ef2 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -0,0 +1,44 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' + +type ExitProofInput = { + identity: AgentSessionProcessIdentity + waitForExit: () => Promise + probe?: (identity: AgentSessionProcessIdentity) => Promise + staleHandleProbeAttempts?: number + staleHandleProbeIntervalMs?: number +} + +const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 +const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 + +function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS + const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (provesRecordedProcessExited(await probe(input.identity))) { + return + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + throw staleError +} + +export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { + try { + await input.waitForExit() + } catch (error) { + if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { + throw error + } + await waitForRecordedProcessExit(input, error) + } +} diff --git a/src/main/runtime/structured-tui-idle-evidence.test.ts b/src/main/runtime/structured-tui-idle-evidence.test.ts new file mode 100644 index 00000000000..7177cb9bfc3 --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' + +describe('structured TUI idle evidence', () => { + it('does not treat a ready prompt preview as proof that a turn is idle', () => { + const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace' + expect(readyPreview).toContain('OpenAI Codex') + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false }) + ).toBe(false) + }) + + it('requires an explicit idle state and still rejects blocked prompts', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true }) + ).toBe(true) + expect( + hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true }) + ).toBe(false) + }) + + it('does not authorize a restored idle status before live observation', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-tui-idle-evidence.ts b/src/main/runtime/structured-tui-idle-evidence.ts new file mode 100644 index 00000000000..d91518cdd4c --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.ts @@ -0,0 +1,9 @@ +import type { AgentStatus } from '../../shared/agent-title-core' + +export function hasStructuredTuiIdleEvidence(input: { + blocked: boolean + status: AgentStatus | null + statusObservedLive: boolean +}): boolean { + return !input.blocked && input.status === 'idle' && input.statusObservedLive +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts new file mode 100644 index 00000000000..d1a7510041f --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' + +describe('structured TUI process identity', () => { + it('binds the direct Codex child instead of the PTY shell pid', async () => { + const readStartTime = vi.fn(async () => 1_700_000_000_000) + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, + { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + ], + readStartTime + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-1' + }) + expect(readStartTime).toHaveBeenCalledWith(101, 'darwin') + }) + + it('fails closed when sibling Codex children make the owner ambiguous', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'win32', + readWindowsRows: async () => [ + { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, + { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, + { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + ], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('waits for a shell-delivered Codex child before binding ownership', async () => { + let snapshots = 0 + const delays: number[] = [] + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-2', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => { + snapshots += 1 + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(snapshots >= 3 + ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume session-1' }] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + timeoutMs: 1_000, + pollIntervalMs: 25, + now: () => delays.length * 25, + sleep: async (delayMs) => { + delays.push(delayMs) + } + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-2' + }) + expect(delays).toEqual([25, 25]) + }) + + it('fails closed when the process snapshot omitted the PTY root', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' } + ] + }) + ).rejects.toThrow('root process was not present') + }) +}) diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts new file mode 100644 index 00000000000..0ce78275f04 --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -0,0 +1,194 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + getFreshProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows' +import { + PROCESS_START_TIME_TOLERANCE_MS, + readProcessStartTimeMs +} from './agent-session-process-identity-probe' + +type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean } + +const STRUCTURED_TUI_PROCESS_WAIT_MS = 5_000 +const STRUCTURED_TUI_PROCESS_POLL_MS = 50 + +function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row]) + } + const found: (ProcessRow & { depth: number })[] = [] + const pending = [{ pid: rootPid, depth: 0 }] + const seen = new Set() + while (pending.length > 0) { + const current = pending.pop()! + if (seen.has(current.pid)) { + continue + } + seen.add(current.pid) + const row = rows.find((candidate) => candidate.pid === current.pid) + if (row) { + found.push({ ...row, depth: current.depth }) + } + for (const child of children.get(current.pid) ?? []) { + pending.push({ pid: child.pid, depth: current.depth + 1 }) + } + } + return found +} + +function excludedProcessTreePids( + rows: ProcessRow[], + rootPids: ReadonlySet | undefined +): ReadonlySet { + if (!rootPids || rootPids.size === 0) { + return new Set() + } + const excluded = new Set(rootPids) + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row.pid]) + } + const pending = [...rootPids] + while (pending.length > 0) { + for (const childPid of children.get(pending.pop()!) ?? []) { + if (!excluded.has(childPid)) { + excluded.add(childPid) + pending.push(childPid) + } + } + } + return excluded +} + +async function resolveExcludedProcessTreePids( + rows: ProcessRow[], + identities: readonly { pid: number; processStartTimeMs: number | null }[] | undefined, + platform: NodeJS.Platform, + readStartTime: (pid: number, platform?: NodeJS.Platform) => Promise +): Promise> { + if (!identities || identities.length === 0) { + return new Set() + } + const roots = new Set() + for (const identity of identities) { + if (!rows.some((row) => row.pid === identity.pid)) { + continue + } + // Unavailable start time cannot prove PID reuse, so retain the conservative exclusion. + if (identity.processStartTimeMs === null) { + roots.add(identity.pid) + continue + } + const observed = await readStartTime(identity.pid, platform) + if ( + observed === null || + Math.abs(observed - identity.processStartTimeMs) <= PROCESS_START_TIME_TOLERANCE_MS + ) { + roots.add(identity.pid) + } + } + return excludedProcessTreePids(rows, roots) +} + +export function resolveStructuredTuiChildPid( + rows: ProcessRow[], + rootPid: number, + agent: AgentSessionHandleProvider, + processCommandMatches?: (command: string) => boolean, + excludedPids?: ReadonlySet +): number | null { + const candidates = descendants(rows, rootPid).filter( + (row) => + !excludedPids?.has(row.pid) && + recognizeAgentProcessFromCommandLine(row.command)?.agent === agent && + (processCommandMatches?.(row.command) ?? true) + ) + const foreground = candidates.filter((row) => row.foreground) + const eligible = foreground.length > 0 ? foreground : candidates + eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid) + if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) { + return null + } + return eligible[0]!.pid +} + +function posixRows(rows: ProcessTableRow[]): ProcessRow[] { + return rows.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: row.stat.includes('+') + })) +} + +export async function readStructuredTuiProcessIdentity(input: { + hostId: string + rootPid: number + spawnToken: string + agent: AgentSessionHandleProvider + platform?: NodeJS.Platform + readPosixRows?: () => Promise + readWindowsRows?: typeof queryWindowsProcessRowsFresh + readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise + timeoutMs?: number + pollIntervalMs?: number + now?: () => number + sleep?: (delayMs: number) => Promise + processCommandMatches?: (command: string) => boolean + excludedProcessTreeRootIdentities?: readonly { + pid: number + processStartTimeMs: number | null + }[] +}): Promise { + const platform = input.platform ?? process.platform + const now = input.now ?? Date.now + const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) + const deadline = now() + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + + while (true) { + const rows: ProcessRow[] = + platform === 'win32' + ? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: false + })) + : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + if (!rows.some((row) => row.pid === input.rootPid)) { + throw new Error('The terminal root process was not present in the process snapshot.') + } + const excludedPids = await resolveExcludedProcessTreePids( + rows, + input.excludedProcessTreeRootIdentities, + platform, + input.readStartTime ?? readProcessStartTimeMs + ) + const pid = resolveStructuredTuiChildPid( + rows, + input.rootPid, + input.agent, + input.processCommandMatches, + excludedPids + ) + if (pid !== null) { + return { + hostId: input.hostId, + pid, + processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform), + spawnToken: input.spawnToken + } + } + const remainingMs = deadline - now() + if (remainingMs <= 0) { + const label = input.agent === 'codex' ? 'Codex' : 'Claude' + throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) + } + await sleep(Math.min(input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS, remainingMs)) + } +} diff --git a/src/main/runtime/structured-tui-recovery-claim-match.test.ts b/src/main/runtime/structured-tui-recovery-claim-match.test.ts new file mode 100644 index 00000000000..39495872b93 --- /dev/null +++ b/src/main/runtime/structured-tui-recovery-claim-match.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + evaluateStructuredTuiRecoveryClaim, + type StructuredTuiRecoveryClaimCandidate +} from './structured-tui-recovery-claim-match' + +// Modeled after a packaged restart whose first recovery claim failed. +const PACKAGED_CANDIDATE: StructuredTuiRecoveryClaimCandidate = { + expectedWorkspaceId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', + claimMatches: true, + pty: { + connected: true, + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + incarnationId: '4cf23679-8987-487d-a24c-dba3bed1b442', + worktreeId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture' + }, + owner: { + phase: 'live', + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + surface: { + worktreeId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', + tabId: 'ced3bd39-262b-41f3-a446-92ceab4f938c', + leafId: 'd4e9d94d-8ec3-4d0d-8ca3-52730ba61c24' + } + }, + persisted: { + sessionResolved: true, + tabPresent: true, + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + incarnationId: null + } +} + +type CandidatePatch = Partial< + Omit +> & { + owner?: Partial> & { + surface?: Partial + } + persisted?: Partial + pty?: Partial +} + +const MISMATCH_CASES: [string, CandidatePatch, string[]][] = [ + ['connection', { pty: { connected: false } }, ['connected']], + ['owner phase', { owner: { phase: 'retiring' } }, ['owner-phase']], + ['owner PTY', { owner: { ptyId: 'different-pty' } }, ['owner-pty-id', 'persisted-pty-id']], + ['presented incarnation', { pty: { incarnationId: null } }, ['presented-incarnation']], + ['PTY workspace', { pty: { worktreeId: 'different-workspace' } }, ['pty-workspace']], + [ + 'surface workspace', + { owner: { surface: { worktreeId: 'different-workspace' } } }, + ['surface-workspace'] + ], + ['claim', { claimMatches: false }, ['claim']], + ['persisted session', { persisted: { sessionResolved: false } }, ['persisted-session']], + ['persisted tab', { persisted: { tabPresent: false } }, ['persisted-tab']], + ['persisted PTY', { persisted: { ptyId: 'different-pty' } }, ['persisted-pty-id']], + [ + 'persisted incarnation', + { persisted: { incarnationId: 'different-incarnation' } }, + ['persisted-incarnation'] + ] +] + +describe('structured TUI packaged recovery claim matching', () => { + it('accepts the real first-claim surface while persisted incarnation hydration is pending', () => { + expect(evaluateStructuredTuiRecoveryClaim(PACKAGED_CANDIDATE)).toEqual({ + matches: true, + mismatchedFields: [] + }) + }) + + it('accepts the same surface once the persisted incarnation arrives', () => { + expect( + evaluateStructuredTuiRecoveryClaim({ + ...PACKAGED_CANDIDATE, + persisted: { + ...PACKAGED_CANDIDATE.persisted, + incarnationId: PACKAGED_CANDIDATE.pty.incarnationId + } + }) + ).toMatchObject({ matches: true }) + }) + + it.each(MISMATCH_CASES)('rejects a %s mismatch', (_label, patch, mismatchedFields) => { + const candidate = { + ...PACKAGED_CANDIDATE, + ...patch, + pty: { ...PACKAGED_CANDIDATE.pty, ...patch.pty }, + owner: { + ...PACKAGED_CANDIDATE.owner, + ...patch.owner, + surface: { ...PACKAGED_CANDIDATE.owner.surface, ...patch.owner?.surface } + }, + persisted: { ...PACKAGED_CANDIDATE.persisted, ...patch.persisted } + } + + expect(evaluateStructuredTuiRecoveryClaim(candidate)).toEqual({ + matches: false, + mismatchedFields + }) + }) +}) diff --git a/src/main/runtime/structured-tui-recovery-claim-match.ts b/src/main/runtime/structured-tui-recovery-claim-match.ts new file mode 100644 index 00000000000..658ae15247c --- /dev/null +++ b/src/main/runtime/structured-tui-recovery-claim-match.ts @@ -0,0 +1,89 @@ +export type StructuredTuiRecoveryClaimMismatch = + | 'claim' + | 'connected' + | 'owner-phase' + | 'owner-pty-id' + | 'persisted-incarnation' + | 'persisted-pty-id' + | 'persisted-session' + | 'persisted-tab' + | 'presented-incarnation' + | 'pty-workspace' + | 'surface-workspace' + +export type StructuredTuiRecoveryClaimCandidate = { + expectedWorkspaceId: string + claimMatches: boolean + pty: { + connected: boolean + ptyId: string + incarnationId: string | null + worktreeId: string + } + owner: { + phase: string + ptyId: string + surface: { + worktreeId: string + tabId: string + leafId: string + } + } + persisted: { + sessionResolved: boolean + tabPresent: boolean + ptyId: string | null + incarnationId: string | null + } +} + +export type StructuredTuiRecoveryClaimEvaluation = { + matches: boolean + mismatchedFields: StructuredTuiRecoveryClaimMismatch[] +} + +export function evaluateStructuredTuiRecoveryClaim( + candidate: StructuredTuiRecoveryClaimCandidate, + worktreeIdsEqual: (left: string, right: string) => boolean = (left, right) => left === right +): StructuredTuiRecoveryClaimEvaluation { + const mismatchedFields: StructuredTuiRecoveryClaimMismatch[] = [] + if (!candidate.pty.connected) { + mismatchedFields.push('connected') + } + if (candidate.owner.phase !== 'live') { + mismatchedFields.push('owner-phase') + } + if (candidate.owner.ptyId !== candidate.pty.ptyId) { + mismatchedFields.push('owner-pty-id') + } + if (!candidate.pty.incarnationId) { + mismatchedFields.push('presented-incarnation') + } + if (!worktreeIdsEqual(candidate.pty.worktreeId, candidate.expectedWorkspaceId)) { + mismatchedFields.push('pty-workspace') + } + if (!worktreeIdsEqual(candidate.owner.surface.worktreeId, candidate.expectedWorkspaceId)) { + mismatchedFields.push('surface-workspace') + } + if (!candidate.claimMatches) { + mismatchedFields.push('claim') + } + if (!candidate.persisted.sessionResolved) { + mismatchedFields.push('persisted-session') + } else { + if (!candidate.persisted.tabPresent) { + mismatchedFields.push('persisted-tab') + } + if (candidate.persisted.ptyId !== candidate.owner.ptyId) { + mismatchedFields.push('persisted-pty-id') + } + // Packaged hydration can omit this binding briefly; daemon incarnation and child proof stay mandatory. + if ( + candidate.persisted.incarnationId !== null && + candidate.persisted.incarnationId !== candidate.pty.incarnationId + ) { + mismatchedFields.push('persisted-incarnation') + } + } + return { matches: mismatchedFields.length === 0, mismatchedFields } +} diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 330052b59e1..9019a10e9b5 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -4,8 +4,9 @@ import { randomUUID } from 'node:crypto' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' -import { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { HostCliUnavailableError, @@ -101,7 +102,7 @@ async function runLegacyRemoteOrcaCli( json: boolean, passthroughFailure: HostCliUnavailableError ): Promise { - const dispatcher = new RpcDispatcher({ runtime }) + const dispatcher = new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } diff --git a/src/main/ssh/ssh-remote-orchestration-post-output.ts b/src/main/ssh/ssh-remote-orchestration-post-output.ts index 5d833651350..3778ea2524d 100644 --- a/src/main/ssh/ssh-remote-orchestration-post-output.ts +++ b/src/main/ssh/ssh-remote-orchestration-post-output.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RpcResponse } from '../runtime/rpc/core' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { RemoteOrcaCliPostOutput, RemoteOrcaCliRequest @@ -39,7 +40,7 @@ export async function acknowledgeRemoteOrcaCliPostOutput( answerMessageId: args.postOutput.answerMessageId }) } - const response = await new RpcDispatcher({ runtime }).dispatch({ + const response = await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch({ id: `remote-cli-post-output-${randomUUID()}`, authToken: 'remote-cli', method: 'orchestration.check', diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index af4c7d609ca..9c26b12292d 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -270,6 +270,20 @@ describe('startup ordering', () => { expect(disposeIndex).toBeGreaterThan(commitIndex) }) + it('joins structured agent sessions to the committed quit barrier', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const willQuitStart = source.indexOf("app.on('will-quit'") + const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) + const willQuit = source.slice(willQuitStart, willQuitEnd) + + expect(willQuit).toContain( + 'const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()' + ) + expect(willQuit).toContain( + "{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown }" + ) + }) + it('joins agent-browser cleanup before the committed quit exits', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const willQuitStart = source.indexOf("app.on('will-quit'") diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 64409dbbb3e..766fda268c6 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -80,7 +80,7 @@ export function _resetHydrateShellPathCache(): void { windowsPathOwnership.reset() } -function pickShell(): string | null { +export function resolveProfileLoadingShell(): string | null { if (process.platform === 'win32') { const family = resolveWindowsShellStartupFamily(configuredWindowsShell) if (family === 'cmd') { @@ -93,12 +93,12 @@ function pickShell(): string | null { return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null } const shell = process.env.SHELL - if (shell && shell.length > 0) { - return shell - } - return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' + return shell?.length ? shell : process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' } +export const resolveProfileLoadingFallbackShell = (): string | null => + configuredWindowsFallbackShell + function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) @@ -289,7 +289,8 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { - it('hydrates after bounded barriers while provider startup remains pending', async () => { + it('waits for daemon adoption before renderer recovery can continue', async () => { let resolveFirstWindow!: () => void let resolveWslBarrier!: () => void let resolveProvider!: () => void @@ -29,14 +34,15 @@ describe('legacy worker renderer recovery', () => { expect(reconcile).not.toHaveBeenCalled() resolveWslBarrier() - await startup - expect(reconcile).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(reconcile).not.toHaveBeenCalled() resolveProvider() - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + await startup + expect(reconcile).toHaveBeenCalledOnce() }) - it('retries after initial recovery when the provider is already ready', async () => { + it('recovers once when the provider is already ready', async () => { const reconcile = vi.fn().mockResolvedValue(undefined) await recoverLegacyWorkerTerminalsForRendererStartup({ @@ -47,7 +53,7 @@ describe('legacy worker renderer recovery', () => { onDeferredRecoveryError: vi.fn() }) - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + expect(reconcile).toHaveBeenCalledOnce() }) it('contains provider startup rejection after initial recovery', async () => { @@ -67,12 +73,12 @@ describe('legacy worker renderer recovery', () => { }) await expect(reportedError).resolves.toBe(providerError) - expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile).not.toHaveBeenCalled() }) - it('contains deferred recovery rejection', async () => { + it('contains recovery rejection', async () => { const recoveryError = new Error('recovery failed') - const reconcile = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(recoveryError) + const reconcile = vi.fn().mockRejectedValueOnce(recoveryError) let reportError!: (error: unknown) => void const reportedError = new Promise((resolve) => { reportError = resolve @@ -87,31 +93,42 @@ describe('legacy worker renderer recovery', () => { }) await expect(reportedError).resolves.toBe(recoveryError) - expect(reconcile).toHaveBeenCalledTimes(2) + expect(reconcile).toHaveBeenCalledOnce() }) - it('contains initial recovery rejection and still retries when the provider becomes ready', async () => { - const initialError = new Error('initial recovery failed') - let resolveProvider!: () => void - const providerReady = new Promise((resolve) => { - resolveProvider = resolve - }) - const reconcile = vi.fn().mockRejectedValueOnce(initialError).mockResolvedValueOnce(undefined) - const onDeferredRecoveryError = vi.fn() - - await expect( - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady: Promise.resolve(), + it('fails open at the hard cap without allowing a premature recovery', async () => { + vi.useFakeTimers() + let daemonSignal: AbortSignal | undefined + try { + const services = startFirstWindowStartupServices({ + startDaemonPtyProvider: (signal) => { + daemonSignal = signal + return new Promise(() => {}) + }, + startAgentHookServer: () => Promise.resolve(), + onDaemonError: vi.fn(), + onAgentHookServerError: vi.fn() + }) + const reconcile = vi.fn().mockResolvedValue(undefined) + const startup = recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: services.firstWindowReady, managedWslCliStartupBarrierReady: Promise.resolve(), - localPtyProviderStartupReady: providerReady, + localPtyProviderStartupReady: services.localPtyProviderReady, reconcile, - onDeferredRecoveryError + onDeferredRecoveryError: vi.fn() }) - ).resolves.toBeUndefined() - expect(onDeferredRecoveryError).toHaveBeenCalledWith(initialError) - expect(reconcile).toHaveBeenCalledTimes(1) - resolveProvider() - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + expect(reconcile).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync( + LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS - FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS + ) + await startup + expect(reconcile).toHaveBeenCalledOnce() + expect(daemonSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index fa149c189bd..4a24f107135 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -13,18 +13,15 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }) ) - await Promise.all([ + const [providerResult] = await Promise.all([ + providerStartupResult, options.firstWindowStartupServicesReady, options.managedWslCliStartupBarrierReady ]) - void providerStartupResult - .then(async (result) => { - if (!result.ok) { - throw result.error - } - await options.reconcile() - }) - .catch(options.onDeferredRecoveryError) + if (!providerResult.ok) { + options.onDeferredRecoveryError(providerResult.error) + return + } try { await options.reconcile() } catch (error) { diff --git a/src/main/startup/login-shell-environment.test.ts b/src/main/startup/login-shell-environment.test.ts new file mode 100644 index 00000000000..33d7e2a7ddb --- /dev/null +++ b/src/main/startup/login-shell-environment.test.ts @@ -0,0 +1,86 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + resetLoginShellEnvironmentCacheForTests, + resolveLoginShellEnvironment +} from './login-shell-environment' + +const originalHome = process.env.HOME +const originalZdotdir = process.env.ZDOTDIR +const SHELL_ONLY_VARIABLE = 'ORCA_TEST_LOGIN_SHELL_ONLY' +const originalShellOnlyValue = process.env[SHELL_ONLY_VARIABLE] +let testHome: string | null = null + +// Why: this case spawns a REAL login shell, so it can only run against one the +// machine actually has. Hardcoding /bin/zsh made it fail on Linux CI, where zsh +// is not installed — the resolver simply returned the parent env and the +// assertion read `undefined`. Each entry pairs a shell with the profile file an +// interactive login shell of that family sources (bash reads .bash_profile when +// it is a login shell, never .bashrc). +const REAL_SHELL_CANDIDATES = [ + { path: '/bin/zsh', profileFile: '.zshenv' }, + { path: '/bin/bash', profileFile: '.bash_profile' } +] as const + +const realShell = REAL_SHELL_CANDIDATES.find((candidate) => existsSync(candidate.path)) ?? null + +afterEach(async () => { + resetLoginShellEnvironmentCacheForTests() + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = originalZdotdir + } + if (originalShellOnlyValue === undefined) { + delete process.env[SHELL_ONLY_VARIABLE] + } else { + process.env[SHELL_ONLY_VARIABLE] = originalShellOnlyValue + } + if (testHome) { + await rm(testHome, { recursive: true, force: true }) + testHome = null + } +}) + +describe('resolveLoginShellEnvironment', () => { + it('returns variables exported by the profile-loading shell', async () => { + const spawner = vi.fn(async () => ({ + ...process.env, + EXAMPLE_GATEWAY_TOKEN: 'shell-exported' + })) + + await expect( + resolveLoginShellEnvironment({ shellOverride: '/bin/zsh', spawner }) + ).resolves.toMatchObject({ EXAMPLE_GATEWAY_TOKEN: 'shell-exported' }) + }) + + it.runIf(realShell !== null)( + 'captures a profile export missing from the parent process', + async () => { + const shell = realShell! + testHome = await mkdtemp(join(tmpdir(), 'orca-login-shell-env-')) + await writeFile( + join(testHome, shell.profileFile), + `export ${SHELL_ONLY_VARIABLE}=shell-only\n` + ) + process.env.HOME = testHome + // zsh reads .zshenv from ZDOTDIR when set; harmless for the bash variant. + process.env.ZDOTDIR = testHome + delete process.env[SHELL_ONLY_VARIABLE] + + const environment = await resolveLoginShellEnvironment({ + shellOverride: shell.path, + force: true + }) + expect(environment[SHELL_ONLY_VARIABLE]).toBe('shell-only') + } + ) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts new file mode 100644 index 00000000000..d456dff64c9 --- /dev/null +++ b/src/main/startup/login-shell-environment.ts @@ -0,0 +1,160 @@ +import { win32 as pathWin32 } from 'node:path' +import { spawnProcess } from '../../shared/child-process/run-process' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { + resolveProfileLoadingFallbackShell, + resolveProfileLoadingShell +} from './hydrate-shell-path' + +const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' +const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' +const SPAWN_TIMEOUT_MS = 5000 + +let cached: Promise | null = null +let cachedShellKey: string | null = null + +function processEnvironment(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) +} + +function shellProbe(shell: string): string[] | null { + if (process.platform !== 'win32' || resolveWindowsShellStartupFamily(shell) === 'posix') { + const command = + `printf '\\0${START_MARKER}\\0'; /usr/bin/env -0; ` + `printf '\\0${END_MARKER}\\0'` + return ['-ilc', command] + } + const basename = pathWin32.basename(shell).toLowerCase() + if (basename !== 'powershell.exe' && basename !== 'pwsh.exe') { + return null + } + const command = + `$values = @{}; [Environment]::GetEnvironmentVariables().GetEnumerator() | ` + + `ForEach-Object { $values[[string]$_.Key] = [string]$_.Value }; ` + + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${START_MARKER}'); ` + + `[Console]::Write(($values | ConvertTo-Json -Compress)); ` + + `[Console]::Write('${END_MARKER}')` + return ['-NoLogo', '-Command', command] +} + +function parsePosixEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const start = output.indexOf(Buffer.from(`\0${START_MARKER}\0`)) + const end = output.indexOf(Buffer.from(`\0${END_MARKER}\0`), start + START_MARKER.length + 2) + if (start === -1 || end === -1) { + return null + } + const bodyStart = start + START_MARKER.length + 2 + const entries = output.subarray(bodyStart, end).toString('utf8').split('\0') + const environment: NodeJS.ProcessEnv = {} + for (const entry of entries) { + const separator = entry.indexOf('=') + if (separator > 0) { + environment[entry.slice(0, separator)] = entry.slice(separator + 1) + } + } + return Object.keys(environment).length > 0 ? environment : null +} + +function parsePowerShellEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const text = output.toString('utf8') + const start = text.indexOf(START_MARKER) + const end = text.indexOf(END_MARKER, start + START_MARKER.length) + if (start === -1 || end === -1) { + return null + } + try { + const parsed = JSON.parse(text.slice(start + START_MARKER.length, end)) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const entries = Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return entries.length > 0 ? Object.fromEntries(entries) : null + } catch { + return null + } +} + +function spawnShellAndReadEnvironment(shell: string): Promise { + const args = shellProbe(shell) + if (!args) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + let settled = false + const chunks: Buffer[] = [] + const child = spawnProcess({ program: shell, args, env: process.env }) + const finish = (value: NodeJS.ProcessEnv | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // Best-effort timeout cleanup. + } + finish(null) + }, SPAWN_TIMEOUT_MS) + child.stdin.on('error', () => {}) + child.stdout.on('error', () => finish(null)) + child.stderr.on('error', () => {}) + child.stdin.end() + child.stderr.resume() + child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + child.on('error', () => finish(null)) + child.on('close', () => { + const output = Buffer.concat(chunks) + finish( + process.platform === 'win32' && resolveWindowsShellStartupFamily(shell) !== 'posix' + ? parsePowerShellEnvironment(output) + : parsePosixEnvironment(output) + ) + }) + }) +} + +export type ResolveLoginShellEnvironmentOptions = { + force?: boolean + shellOverride?: string | null + spawner?: (shell: string) => Promise +} + +/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ +export function resolveLoginShellEnvironment( + options: ResolveLoginShellEnvironmentOptions = {} +): Promise { + const shell = + options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() + const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null + const shellKey = `${shell ?? ''}\0${fallback ?? ''}` + if (cached && cachedShellKey === shellKey && !options.force) { + return cached + } + if (!shell) { + return Promise.resolve(processEnvironment()) + } + const spawner = options.spawner ?? spawnShellAndReadEnvironment + cachedShellKey = shellKey + cached = spawner(shell) + .then(async (environment) => { + if (environment) { + return environment + } + return fallback ? ((await spawner(fallback)) ?? processEnvironment()) : processEnvironment() + }) + .catch(() => processEnvironment()) + return cached +} + +export function resetLoginShellEnvironmentCacheForTests(): void { + cached = null + cachedShellKey = null +} diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 7468b2215ad..8587497d318 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -3,7 +3,10 @@ import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { resolveCliCommand } from '../codex-cli/command' import { wslAwareSpawn } from '../git/runner' import { getSpawnArgsForWindows } from '../win32-utils' -import type { SpawnSourceControlAgent } from './source-control-text-generation-types' +import type { + SpawnedSourceControlAgentProcess, + SpawnSourceControlAgent +} from './source-control-text-generation-types' const WSL_LAUNCHER_ENV_KEYS = [ 'ComSpec', @@ -36,6 +39,7 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { const spawnEnv = input.env ?? process.env if (process.platform === 'win32' && input.wslDistro) { + // Same contract as spawnProcess: stdout/stderr are piped; stdin matches stdinMode. return wslAwareSpawn(input.binary, input.args, { cwd: input.cwd, env: buildWslLauncherEnv(input.env), @@ -43,7 +47,7 @@ export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { windowsHide: true, wslDistro: input.wslDistro, useWslLoginShell: true - }) + }) as SpawnedSourceControlAgentProcess } const resolvedBinary = process.platform === 'win32' diff --git a/src/main/windows-pty-root-identity.test.ts b/src/main/windows-pty-root-identity.test.ts index 8ce8e76b63c..3614f639471 100644 --- a/src/main/windows-pty-root-identity.test.ts +++ b/src/main/windows-pty-root-identity.test.ts @@ -175,7 +175,7 @@ describe('verifyWindowsTreeKillTarget scan volume', () => { cb(withSelf(NATIVE_ROWS)) }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 8bbd0ed7ccb..609de009820 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -4,6 +4,7 @@ import { __setWindowsProcessTreeLoaderForTests, __setWindowsProcessTreeRequireForTests, isWindowsProcessTableAvailable, + isWindowsProcessStartTimeAvailable, readWindowsProcessTable, readWindowsProcessTableFresh, resetWindowsProcessTableForTests @@ -17,7 +18,14 @@ const getAllProcesses = vi.fn() const SELF = { pid: process.pid, ppid: 0, name: 'vitest.exe' } const NATIVE = [ SELF, - { pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', memory: 4096 } + { + pid: 100, + ppid: 4, + name: 'orca.exe', + commandLine: '"C:/a b/orca.exe" --x', + memory: 4096, + creationTimeMs: 1_700_000_000_000 + } ] describe('windows process table', () => { @@ -29,7 +37,7 @@ describe('windows process table', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) }) @@ -50,14 +58,24 @@ describe('windows process table', () => { ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096 + memoryBytes: 4096, + creationTimeMs: 1_700_000_000_000 } ]) }) it('requests memory and command line together', async () => { await readWindowsProcessTableFresh() - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(3) + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) + }) + + it('only advertises PID-safe ownership when the native creation-time field exists', () => { + expect(isWindowsProcessStartTimeAvailable()).toBe(true) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + expect(isWindowsProcessStartTimeAvailable()).toBe(false) }) it('serves repeat reads from the shared snapshot', async () => { @@ -208,7 +226,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -230,7 +248,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -316,7 +334,7 @@ describe('sticky wedge', () => { throw new Error('addon exploded') }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -325,7 +343,7 @@ describe('sticky wedge', () => { // The recovered reader must answer, not report a wedge left by a dead timer. __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: (cb: (rows: typeof NATIVE | undefined) => void) => cb(NATIVE) })) await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) @@ -388,7 +406,8 @@ describe('resolving the native reader', () => { ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096 + memoryBytes: 4096, + creationTimeMs: 1_700_000_000_000 } ]) expect(isWindowsProcessTableAvailable()).toBe(true) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 35dab09d100..9308c64a9f0 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -33,6 +33,8 @@ export type WindowsProcessRow = { command: string /** Working set in bytes, or undefined when not requested/queryable. */ memoryBytes?: number + /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ + creationTimeMs?: number } type NativeProcessInfo = { @@ -41,10 +43,16 @@ type NativeProcessInfo = { name: string memory?: number commandLine?: string + creationTimeMs?: number } type WindowsProcessTreeModule = { - ProcessDataFlag: { None: number; Memory: number; CommandLine: number } + ProcessDataFlag: { + None: number + Memory: number + CommandLine: number + CreationTime?: number + } getAllProcesses: ( callback: (processes: NativeProcessInfo[] | undefined) => void, flags?: number @@ -188,7 +196,10 @@ function readNativeRows(): Promise { // one snapshot so a 32-wide teardown collapses into a single scan, and that // snapshot has to satisfy every caller. Splitting the cache per field set // would restore exactly the fan-out it exists to prevent. - const flags = native.ProcessDataFlag.Memory | native.ProcessDataFlag.CommandLine + const flags = + native.ProcessDataFlag.Memory | + native.ProcessDataFlag.CommandLine | + (native.ProcessDataFlag.CreationTime ?? 0) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -230,7 +241,10 @@ function readNativeRows(): Promise { ppid: row.ppid, name: row.name, command: row.commandLine ?? '', - memoryBytes: row.memory + memoryBytes: row.memory, + ...(typeof row.creationTimeMs === 'number' + ? { creationTimeMs: row.creationTimeMs } + : {}) })) ) }, flags) @@ -284,6 +298,17 @@ export function isWindowsProcessTableAvailable(): boolean { return moduleLoader() !== null } +/** + * PID-reuse-safe ownership needs the native creation-time field, not merely a + * process list. Older addon builds expose the table without that field; keep + * structured ownership unavailable on those hosts instead of fabricating proof + * from a PID. + */ +export function isWindowsProcessStartTimeAvailable(): boolean { + const native = moduleLoader() + return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number' +} + /** * Test-only: substitute the native module. * diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 1e5e9f013fb..88cb86dc32b 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -38,6 +38,8 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ + prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 25907848e3c..ae8db3cedf5 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -26,6 +26,10 @@ export type RuntimeApi = { ) => Promise getStatus: () => Promise call: (args: { method: string; params?: unknown }) => Promise> + subscribe: ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ) => Promise getTerminalFitOverrides: () => Promise< { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > diff --git a/src/preload/index.ts b/src/preload/index.ts index 07fe20566a5..a474eb7560e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,7 @@ import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload } from '../shared/terminal-preview' +import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' @@ -590,6 +591,8 @@ const api = { }, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => @@ -1084,9 +1087,17 @@ const api = { }, writeAccepted: (id: string, data: string): Promise => ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => - callback(payload) + onWriteUnavailable: ( + callback: (payload: { + id: string + /** Set only when a durable agent-session lease refused the write; absent otherwise. */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } + ): void => callback(payload) ipcRenderer.on('pty:writeUnavailable', handler) return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) }, @@ -4618,6 +4629,31 @@ const api = { getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), call: (args: { method: string; params?: unknown }): Promise> => ipcRenderer.invoke('runtime:call', args), + subscribe: async ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ): Promise => { + const subscriptionId = `desktop-${crypto.randomUUID()}` + const channel = `runtime:subscription:${subscriptionId}` + const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => + callback(response) + ipcRenderer.on(channel, listener) + try { + await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + } catch (error) { + ipcRenderer.removeListener(channel, listener) + throw error + } + return { + unsubscribe: () => { + ipcRenderer.removeListener(channel, listener) + ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) + }, + sendBinary: () => { + throw new Error('Local runtime subscriptions do not accept binary input') + } + } + }, getTerminalFitOverrides: (): Promise< { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index 4740481e34f..014b3f69bca 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -6,6 +6,7 @@ import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGa import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' import { useAppStore } from '../store' +import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge' const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) @@ -33,6 +34,7 @@ export function AppBackgroundServices(): React.JSX.Element { ) : null} + ) } diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 68db2874593..e969609c268 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -15,6 +15,7 @@ import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerE import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling' import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' +import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' /** @@ -31,6 +32,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() + useLocalStructuredSessionTabsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/app-shell/use-app-startup-actions.ts b/src/renderer/src/app-shell/use-app-startup-actions.ts new file mode 100644 index 00000000000..df7cec71ab1 --- /dev/null +++ b/src/renderer/src/app-shell/use-app-startup-actions.ts @@ -0,0 +1,39 @@ +// The renderer boot chain's store subscription, kept apart from the chain itself +// so one useShallow equality check covers every startup action. + +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../store' + +export function useStartupActions() { + // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. + return useAppStore( + useShallow((s) => ({ + fetchReposForAllHosts: s.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, + fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, + fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, + fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktrees: s.fetchWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, + fetchOrcaProfiles: s.fetchOrcaProfiles, + fetchSettings: s.fetchSettings, + awaitOwnerWorktreeVisibilityDefaultsHydration: + s.awaitOwnerWorktreeVisibilityDefaultsHydration, + fetchKeybindings: s.fetchKeybindings, + initGitHubCache: s.initGitHubCache, + hydrateWorkspaceSession: s.hydrateWorkspaceSession, + hydrateTabsSession: s.hydrateTabsSession, + hydrateEditorSession: s.hydrateEditorSession, + hydrateBrowserSession: s.hydrateBrowserSession, + fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, + reconnectPersistedTerminals: s.reconnectPersistedTerminals, + setTerminalStartupRestorationReady: s.setTerminalStartupRestorationReady, + setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, + setSshConnectionState: s.setSshConnectionState, + hydratePersistedUI: s.hydratePersistedUI, + setHydrationSucceeded: s.setHydrationSucceeded, + pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, + seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing + })) + ) +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 515f084708c..091039fb793 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react' -import { useShallow } from 'zustand/react/shallow' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' import { useAppStore } from '../store' +import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' @@ -33,6 +33,7 @@ import { } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' +import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' async function listRuntimeSessionHostIdsForStartup(): Promise { try { @@ -45,39 +46,6 @@ async function listRuntimeSessionHostIdsForStartup(): Promise } } -function useStartupActions() { - // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. - return useAppStore( - useShallow((s) => ({ - fetchReposForAllHosts: s.fetchReposForAllHosts, - awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, - fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, - fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, - fetchAllWorktrees: s.fetchAllWorktrees, - fetchWorktrees: s.fetchWorktrees, - fetchWorktreeLineage: s.fetchWorktreeLineage, - fetchOrcaProfiles: s.fetchOrcaProfiles, - fetchSettings: s.fetchSettings, - awaitOwnerWorktreeVisibilityDefaultsHydration: - s.awaitOwnerWorktreeVisibilityDefaultsHydration, - fetchKeybindings: s.fetchKeybindings, - initGitHubCache: s.initGitHubCache, - hydrateWorkspaceSession: s.hydrateWorkspaceSession, - hydrateTabsSession: s.hydrateTabsSession, - hydrateEditorSession: s.hydrateEditorSession, - hydrateBrowserSession: s.hydrateBrowserSession, - fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, - reconnectPersistedTerminals: s.reconnectPersistedTerminals, - setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, - setSshConnectionState: s.setSshConnectionState, - hydratePersistedUI: s.hydratePersistedUI, - setHydrationSucceeded: s.setHydrationSucceeded, - pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, - seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing - })) - ) -} - /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session @@ -226,6 +194,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) }) + await timeRendererStartupStep('prepare-terminal-startup-restoration', () => + window.api.app.prepareTerminalStartupRestoration() + ) + if (cancelled) { + return + } // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. // See docs/cmd-j-empty-query-ordering.md. timeRendererStartupSyncStep('visit-timestamp-prune', () => { @@ -275,12 +249,19 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) + if (cancelled) { + return + } // Why here: reconnect just published restored PTY ids; sweeping them now // re-offers stale Codex panes whose tabs never mount this session. sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState()) syncZoomCSSVar() // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. actions.setHydrationSucceeded(true) + actions.setTerminalStartupRestorationReady(true) logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index e7b7cfe1fef..94034900e5e 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -19,6 +19,20 @@ const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-per const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' describe('renderer startup runtime routing', () => { + it('routes packaged terminal restore through the daemon adoption gate', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + const gateStart = source.indexOf('const startupActivationGateWorktreeIdsRef') + const gateEnd = source.indexOf('const startupResumeWorktreeIdsRef', gateStart) + const gateEffect = source.slice(gateStart, gateEnd) + + expect(gateStart).toBeGreaterThanOrEqual(0) + expect(gateEffect).toContain('void gateWorktreeAgentActivation(activeWorktreeId)') + expect(gateEffect).not.toContain('resumeSleepingAgentSessionsForWorktree') + }) + it('hydrates persisted UI before local catalog and worktree hydration', () => { const source = readSource(STARTUP_HYDRATION_PATH) const startupBlockStart = source.indexOf('void (async () => {') @@ -325,6 +339,46 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeGreaterThan(capabilityIndex) }) + it('orders packaged restoration before adoption, projection, and default creation', () => { + // Why this file: the startup sequence moved out of App.tsx into the hydration hook; + // the ordering it asserts is unchanged, only the module that now spells it out. + const appSource = readFileSync( + join(process.cwd(), 'src/renderer/src/app-shell/use-app-startup-hydration.ts'), + 'utf8' + ) + const terminalSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") + const prepareIndex = appSource.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) + const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'") + const projectIndex = appSource.indexOf( + "timeRendererStartupStep('project-structured-session-tabs'" + ) + const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)') + const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef') + const gateEnd = terminalSource.indexOf('const startupResumeWorktreeIdsRef', gateStart) + const gateBlock = terminalSource.slice(gateStart, gateEnd) + const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)') + const createIndex = gateBlock.indexOf( + 'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })' + ) + + expect(hydrateIndex).toBeGreaterThanOrEqual(0) + expect(hydrateIndex).toBeLessThan(prepareIndex) + expect(prepareIndex).toBeLessThan(reconnectIndex) + expect(reconnectIndex).toBeLessThan(projectIndex) + expect(projectIndex).toBeLessThan(readyIndex) + expect(gateBlock).toContain('terminalStartupRestorationReady') + expect(gateBlock).not.toContain('hydrationSucceeded') + expect(gateIndex).toBeGreaterThanOrEqual(0) + expect(gateIndex).toBeLessThan(createIndex) + expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome !== 'empty'") + }) + it('does not load the terminal workbench on the no-workspace landing path', () => { const shellSource = readSource(WORKSPACE_SHELL_PATH) const layoutSource = readSource(CHROME_LAYOUT_PATH) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index cd556d6f486..1b5f40ccdb4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -389,6 +389,12 @@ [data-sonner-toaster] { font-family: var(--font-sans); + z-index: 40 !important; +} + +/* Keep interruption controls above unrelated updater/onboarding chrome. */ +.native-chat-pane-shell:has([data-native-chat-working='true']) { + z-index: 50; } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1889c1b7bbd..f5857a33e13 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -44,6 +44,7 @@ import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' +import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { onBrowserGuestPaintRetentionChange, @@ -157,6 +158,7 @@ import { } from '@/runtime/web-runtime-session' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { listBoundAgentTabActions, resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' import { terminalProviderHasAuthoritativeSnapshot } from './terminal/terminal-provider-snapshot-capability' @@ -374,6 +376,7 @@ function Terminal(): React.JSX.Element | null { const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const terminalStartupRestorationReady = useAppStore((s) => s.terminalStartupRestorationReady) const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded) const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted) const openFiles = useAppStore((s) => s.openFiles) @@ -1511,7 +1514,9 @@ function Terminal(): React.JSX.Element | null { ]) // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher. useEffect(() => () => disposeAllParkedTerminalWatchers(), []) - // Auto-create first tab when worktree activates + const startupActivationGateWorktreeIdsRef = useRef(new Set()) + // Why (main): a missing row means never initialized, an explicit empty row means the user + // closed the last terminal — so the gate must not re-seed one in the second case. const activeWorktreeHasTerminalState = activeWorktreeId ? Object.hasOwn(tabsByWorktree, activeWorktreeId) : false @@ -1525,10 +1530,7 @@ function Terminal(): React.JSX.Element | null { ) const activeWorktreeHostAuthority = useAppStore(hostAuthoritySelector) useEffect(() => { - if (!workspaceSessionReady) { - return - } - if (!activeWorktreeId) { + if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) { return } // Why: the execution host owns terminal creation, and a host that has not answered is not a host @@ -1536,21 +1538,37 @@ function Terminal(): React.JSX.Element | null { if (activeWorktreeHostAuthority !== 'none') { return } - - // Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab. - const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) - if (!shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { + if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) { return } - // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). - createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) + startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId) + let cancelled = false + void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => { + if ( + cancelled || + outcome !== 'empty' || + useAppStore.getState().activeWorktreeId !== activeWorktreeId + ) { + return + } + // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. + const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) + if (shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { + // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). + createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) + } + }) + return () => { + cancelled = true + } }, [ - workspaceSessionReady, activeWorktreeId, activeWorktreeHasTerminalState, activeWorktreeHostAuthority, createTab, - reconcileWorktreeTabModel + reconcileWorktreeTabModel, + terminalStartupRestorationReady, + workspaceSessionReady ]) const startupResumeWorktreeIdsRef = useRef(new Set()) @@ -2926,6 +2944,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ {isVisible || backgroundMountTabIds === null ? ( ) : null} + ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 2cbe63a9576..d729bc3cbef 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -226,6 +226,7 @@ import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { Worktree } from '../../../shared/worktree/types' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -879,9 +880,7 @@ function WorktreeJumpPaletteContent({ const [dialogElement, setDialogElement] = useState(null) const previousWorktreeIdRef = useRef(null) - const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'simulator'>( - 'terminal' - ) + const previousActiveTabTypeRef = useRef('terminal') const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') // Why: the exact element focused before Cmd+J opened, so Escape restores it precisely (not a background worktree's hidden terminal). diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 74476af1fb2..d5dac1efab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -89,6 +89,24 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('Loading skills...')).toHaveLength(2) }) + it('renders a retryable error instead of the loading spinner when discovery fails', () => { + const onRetry = vi.fn() + render( + + ) + + expect(screen.getAllByText('Could not load skills from this host')).toHaveLength(2) + expect(screen.queryByText('Loading skills...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledOnce() + }) + it('uses command-only empty copy for a picker without skill support', () => { render( ({ onCompositionEnd?: (event: { currentTarget: HTMLTextAreaElement }) => void sessionOptionsSurface?: SessionOptionsSurface | null sessionOptionsSnapshot?: SessionOptionDescriptor[] + attachDisabled?: boolean } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', confirmationObserver: null as { @@ -31,6 +32,7 @@ const mocks = vi.hoisted(() => ({ createClaudeModelSwitchConfirmationObserver: vi.fn(), discoverCommitMessageModels: vi.fn(), draft: 'hello', + imageAttachments: [] as { id: string; path: string }[], getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), @@ -109,7 +111,7 @@ vi.mock('./use-native-chat-skills', () => ({ })) vi.mock('./use-native-chat-composer-attachments', () => ({ useNativeChatComposerAttachments: () => ({ - imageAttachments: [], + imageAttachments: mocks.imageAttachments, attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), removeImageAttachment: vi.fn() @@ -150,6 +152,7 @@ describe('NativeChatComposer', () => { mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' mocks.draft = 'hello' + mocks.imageAttachments = [] mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -244,6 +247,85 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('routes structured sends and hydrated options through the existing composer', async () => { + const send = vi.fn(() => true) + const dispatchCommand = vi.fn(async () => ({ + handled: false, + accepted: false, + error: null + })) + const optionsSurface = { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + } satisfies SessionOptionsSurface + const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[] + render( + + ) + + expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface) + expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot) + expect(mocks.fieldProps?.attachDisabled).toBe(false) + await act(async () => mocks.fieldProps?.onSend?.()) + + expect(dispatchCommand).toHaveBeenCalledWith('hello') + expect(send).toHaveBeenCalledWith('hello', []) + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(mocks.setDraft).toHaveBeenCalledWith('') + }) + + it('sends structured image attachments through the durable transport', async () => { + mocks.draft = '' + mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }] + const send = vi.fn(() => true) + render( + ({ + handled: false, + accepted: false, + error: null + })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + worktreeId: 'wt-1', + onError: vi.fn(), + runtime: 'local' + }} + /> + ) + + await act(async () => mocks.fieldProps?.onSend?.()) + + expect(send).toHaveBeenCalledWith('', mocks.imageAttachments) + expect(mocks.setDraft).toHaveBeenCalledWith('') + }) + it('types Codex slash composer sends instead of pasting them', () => { mocks.draft = '/status' render( diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index a0fec999d8a..256e7878f7a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -2,16 +2,11 @@ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { - sendNativeChatMessage, - sendNativeChatTypedCommand, - submitNativeChatPrompt -} from './native-chat-runtime-send' -import type { NativeChatSendHandle } from './native-chat-runtime-send' -import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' -import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' -import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' +import { + isStructuredAgentSessionComposerCommand, + STRUCTURED_AGENT_SESSION_SLASH_COMMANDS +} from '../../../../shared/structured-agent-session-composer' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, @@ -23,10 +18,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' -import { - nativeChatComposerTargetIsRemote, - type NativeChatResolvedTarget -} from './native-chat-composer-target' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' @@ -43,6 +35,8 @@ import type { NativeChatComposerHandle, NativeChatComposerProps } from './native-chat-composer-types' +import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' export type { NativeChatComposerHandle, @@ -79,7 +73,8 @@ export const NativeChatComposer = forwardRef getVerifiedNativeChatCommands(agent), [agent]) + const agentCommands = useMemo( + () => + structuredTransport + ? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS + : getVerifiedNativeChatCommands(agent), + [agent, structuredTransport] + ) const picker = useNativeChatPickerState({ agent, terminalTabId, @@ -160,7 +161,9 @@ export const NativeChatComposer = forwardRef { setCaret(el.selectionStart ?? el.value.length) @@ -169,6 +172,7 @@ export const NativeChatComposer = forwardRef { - const text = draft - const imagePaths = imageAttachments.map((attachment) => attachment.path) - if ((text.trim() === '' && imagePaths.length === 0) || disabled) { - return - } - // Why: block a normal send while a session-option command (e.g. /model) is - // still writing its body+delayed-Enter to the same pty, so the two write - // sequences can't interleave on one input line. - if (isDispatchingSessionOption) { - return - } - const target = resolveTarget() - if (!target) { - return - } - const classification = classifySend(text) - // A parked launch draft must be cleared line-by-line before the body. - const { sendOptions } = resolveNativeChatLaunchDraftSend({ - launchDraft, - launchDraftResolved, + const sendStructured = useCallback( + (text: string, attachments = imageAttachments): void => { + if (!structuredTransport) { + return + } + if (attachments.length > 0 && isStructuredAgentSessionComposerCommand(text, agent)) { + structuredTransport.onError('Remove attachments before using a chat-session command.') + return + } + void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) + .then(({ accepted, error }) => { + structuredTransport.onError(error) + if (!accepted) { + return + } + emitNativeChatMessageSent({ agent, runtime: structuredTransport.runtime }) + setHistory((previous) => pushHistory(previous, text)) + setDraft('') + setCaret(0) + clearSkillOrigin() + clearImageAttachments() + }) + .catch((error) => + structuredTransport.onError(error instanceof Error ? error.message : String(error)) + ) + }, + [ agent, - readScreen: () => readTerminalScreen?.() - }) - let pendingHandle: NativeChatSendHandle | null = null - // Why: image attachments take the attachment send path even for a - // command/unknown send, otherwise `clearImageAttachments()` below drops - // them silently when the text starts with the agent's slash/skill prefix. - if (classification !== 'chat' && imagePaths.length === 0) { - pendingHandle = - agent === 'codex' && isSlashCommandDraft(text) - ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) - : sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) - } else if (imagePaths.length > 0) { - pendingHandle = sendNativeChatMessageWithImageAttachments( - target.settings, - target.ptyId, - text, - imagePaths, - sendOptions - ) - } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) - } else { - submitNativeChatPrompt(target.settings, target.ptyId) - } - if (classification !== 'chat') { - if (pendingHandle) { - trackPendingSend(pendingHandle) - } - // Why: only verified catalog commands can truthfully claim they ran or - // mutate session-option state; unknown slash-like text has no such proof. - if (classification === 'command') { - onSlashCommand?.(text.trim()) - sessionOptionsSurface?.recordOutgoingCommand(text.trim()) - } - } else { - const pendingId = onOptimisticSend?.(text, imagePaths) - if (pendingHandle) { - trackPendingSend(pendingHandle, pendingId) - } - } - // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The - // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. - emitNativeChatMessageSent({ - agent, - runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' - }) - setHistory((prev) => pushHistory(prev, text)) - setDraft('') - setCaret(0) - clearSkillOrigin() - clearImageAttachments() - setNotice(null) - // The send cleared the TUI input line before its body, so retire the seed. - useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId) - }, [ + clearImageAttachments, + clearSkillOrigin, + imageAttachments, + setDraft, + structuredTransport + ] + ) + + const sendPty = useNativeChatPtyComposerSend({ agent, - classifySend, - clearSkillOrigin, - clearImageAttachments, draft, imageAttachments, disabled, @@ -325,13 +289,26 @@ export const NativeChatComposer = forwardRef { + if (!structuredTransport) { + sendPty() + } else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) { + sendStructured(draft, imageAttachments) + } + }, [disabled, draft, imageAttachments, sendPty, sendStructured, structuredTransport]) const interrupt = useCallback(() => { cancelPendingSends() @@ -346,13 +323,13 @@ export const NativeChatComposer = forwardRef[0]) => { + if (structuredTransport) { + sendStructured(`/${command.name}`) + return + } + dispatchPtyPickerCommand(command) + }, + [dispatchPtyPickerCommand, sendStructured, structuredTransport] + ) const handleKeyDown = useNativeChatComposerKeyDown({ autocomplete, @@ -448,6 +435,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx index 2e1b02ac249..99db859aab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -59,4 +59,59 @@ describe('NativeChatComposerActions', () => { const dictation = screen.getByRole('button', { name: 'Start dictation' }) expect(pickers.nextElementSibling).toBe(dictation) }) + + it('marks the streaming Stop control as the critical hit target', () => { + render( + + ) + + expect( + screen + .getByRole('button', { name: 'Stop the agent' }) + .getAttribute('data-native-chat-critical-action') + ).toBe('stop') + }) + + it('ignores the second click of a double-click after send becomes Stop', () => { + const onSend = vi.fn() + const onStop = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Stop the agent' }), { detail: 2 }) + + expect(onSend).not.toHaveBeenCalled() + expect(onStop).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index aee0f79c3c0..3ad7f6ab2be 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -7,6 +7,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -23,6 +24,7 @@ export type NativeChatComposerActionsProps = { onStop?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] + sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null } export function NativeChatComposerActions({ @@ -39,8 +41,21 @@ export function NativeChatComposerActions({ onSend, onStop, sessionOptionsSurface, - sessionOptionsSnapshot + sessionOptionsSnapshot, + sessionOptionsPickerRequest }: NativeChatComposerActionsProps): React.JSX.Element { + const handleCriticalAction = (event: React.MouseEvent): void => { + // A double-click commonly lands after the first send has started and the button has + // changed to Stop; ignore the second click instead of cancelling the new turn. + if (event.detail > 1) { + return + } + if (isWorking) { + onStop?.() + } else { + onSend() + } + } const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -73,6 +88,7 @@ export function NativeChatComposerActions({ surface={sessionOptionsSurface} snapshot={sessionOptionsSnapshot} isWorking={isWorking} + pickerRequest={sessionOptionsPickerRequest} /> @@ -120,13 +136,14 @@ export function NativeChatComposerActions({ + + ) : null} + {controller.error || composerError ? ( +

+ {controller.error ?? composerError} +

+ ) : null} + {prompt ? null : ( + { + if (controller.turnId) { + void controller.cancel(controller.turnId) + } + }} + structuredTransport={structuredTransport} + /> + )} + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index fa5358a3fad..a092b8f00bb 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { NativeChatToolRun } from './NativeChatToolRun' afterEach(cleanup) @@ -24,4 +26,67 @@ describe('NativeChatToolRun', () => { expect(screen.getByTitle('src/index.ts')).toHaveTextContent('src/index.ts') expect(screen.queryByTitle('{"file_path":"src/index.ts","offset":10}')).toBeNull() }) + + it('renders structured apply_patch changes as a reviewable diff instead of JSON', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'apply_patch', + input: { + changes: [ + { + path: '/repo/src/app.ts', + kind: { type: 'update', move_path: null }, + diff: '@@ -1 +1 @@\n-before\n+after' + } + ] + } + } + ] + + const { container } = render() + + expect(screen.getByText('+after')).toBeInTheDocument() + expect(screen.getByText('-before')).toBeInTheDocument() + expect(container.querySelector('pre')).toBeNull() + }) + + it('renders evidence-shaped projected patches as colored diffs without changes JSON', () => { + const item: AgentJournalRenderItem = { + itemId: 'apply-patch', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'tool-call', + name: 'apply_patch', + input: { + changes: [ + { + path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-before\n+after' + } + ] + }, + state: 'completed' + } + } + const projected = projectStructuredItemToNativeChat(item) + + expect(projected).not.toBeNull() + const { container } = render( + + ) + + expect(screen.getByText('+after')).toHaveClass( + 'bg-emerald-500/10', + 'text-[var(--git-decoration-added)]' + ) + expect(screen.getByText('-before')).toHaveClass( + 'bg-rose-500/10', + 'text-[var(--git-decoration-deleted)]' + ) + expect(container).not.toHaveTextContent('"changes"') + expect(container.querySelector('pre')).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index ed798188fcc..f4172a1d7a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { useNativeChatRetainedSession } from './use-native-chat-retained-session' @@ -13,7 +12,6 @@ import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatSessionGate } from './NativeChatSessionGate' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { findTabAgentEntry } from './native-chat-tab-agent-entry' import { shouldClearNativeChatWorkingSuppression, shouldShowNativeChatWorking @@ -49,16 +47,26 @@ import { emptyNativeChatContextMenuActions, useNativeChatContextMenu } from './use-native-chat-context-menu' -import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native-chat-view-types' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { useNativeChatStatusEntry } from './use-native-chat-status-entry' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' export type { NativeChatViewProps } from './native-chat-view-types' /** Resolves an agent terminal into its native conversation and composer UI. */ -export default function NativeChatView({ +export default function NativeChatView(props: NativeChatViewProps): React.JSX.Element { + if (props.mode === 'structured') { + return + } + return +} + +function NativeChatBridgeView({ terminalTabId, isVisible, paneKey: preferredPaneKey, @@ -67,21 +75,13 @@ export default function NativeChatView({ resolvedAgent, onSwitchToTerminal, readTerminalScreen, - contextMenuActions -}: NativeChatViewProps): React.JSX.Element { - // Select only this tab's status entry (shallow-compared) so an unrelated - // pane's status tick doesn't re-render this view or re-run the resolution. - const agentStatusEntry = useAppStore( - useShallow((s) => - preferredPaneKey - ? s.agentStatusByPaneKey[preferredPaneKey] - : findTabAgentEntry(s.agentStatusByPaneKey, terminalTabId) - ) + contextMenuActions, + orchestrationDispatchStatus +}: Exclude): React.JSX.Element { + const { entry: agentStatusEntry, paneKey } = useNativeChatStatusEntry( + terminalTabId, + preferredPaneKey ) - - // paneKey: prefer the live entry's key; fall back to the tab id so the hook - // still has a stable key to select live status by before any pane reports. - const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:` return ( )} @@ -118,7 +119,8 @@ function NativeChatResolvedView({ terminalTabId, onSwitchToTerminal, readTerminalScreen, - contextMenuActions + contextMenuActions, + orchestrationDispatchStatus }: NativeChatResolvedViewProps): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to // the remote runtime host for a runtime-owned pane; null keeps the local path. @@ -171,9 +173,7 @@ function NativeChatResolvedView({ // The question card's free-text row; keeps Paste working while the card // replaces the composer. const questionAnswerInputRef = useRef(null) - const fileLinkContext = useAppStore( - useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId)) - ) + const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) const pasteClipboardIntoComposer = useNativeChatPasteBridge({ rootRef, composerRef, @@ -181,7 +181,6 @@ function NativeChatResolvedView({ }) const contextMenu = useNativeChatContextMenu({ rootRef, - onSwitchToTerminal, actions: { onPaste: pasteClipboardIntoComposer, ...(contextMenuActions ?? emptyNativeChatContextMenuActions) @@ -367,6 +366,7 @@ function NativeChatResolvedView({
{ if (event.button === 2) { @@ -400,6 +400,7 @@ function NativeChatResolvedView({ onContextMenuCapture={contextMenu.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx new file mode 100644 index 00000000000..25991eac3ff --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../../shared/tab-types' + +type MockAppState = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + runtimeEnvironmentId: string | null + executionHostId: string + focusGroup: (worktreeId: string, groupId: string) => void +} + +const mocks = vi.hoisted(() => ({ + store: null as null | { setState: (state: Partial) => void }, + focusGroup: vi.fn(), + mountsByTabId: new Map(), + unmountsByTabId: new Map() +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + })) + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: MockAppState) => state.runtimeEnvironmentId, + getExecutionHostIdForWorktree: (state: MockAppState) => state.executionHostId +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string | null + }) => + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +vi.mock('./NativeChatView', async () => { + const { useEffect } = await import('react') + return { + default: function MockNativeChatView({ + tabId, + isVisible + }: { + tabId: string + isVisible: boolean + }) { + useEffect(() => { + mocks.mountsByTabId.set(tabId, (mocks.mountsByTabId.get(tabId) ?? 0) + 1) + return () => { + mocks.unmountsByTabId.set(tabId, (mocks.unmountsByTabId.get(tabId) ?? 0) + 1) + } + }, [tabId]) + return ( + + ) + } + } +}) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' + +const WORKTREE_ID = 'wt-1' +const GROUP_ID = 'group-1' +const FIRST_TAB_ID = 'structured-agent-session-session-1' +const SECOND_TAB_ID = 'structured-agent-session-session-2' + +describe('StructuredAgentSessionPaneOverlayLayer', () => { + beforeEach(() => { + mocks.focusGroup.mockClear() + mocks.mountsByTabId.clear() + mocks.unmountsByTabId.clear() + mocks.store?.setState(createState(FIRST_TAB_ID)) + }) + + afterEach(cleanup) + + it('keeps materialized chat surfaces mounted while activation only swaps visibility', () => { + const view = render( + + ) + const firstBefore = chatSurface(view.container, FIRST_TAB_ID) + const secondBefore = chatSurface(view.container, SECOND_TAB_ID) + + expect(firstBefore.dataset.chatVisible).toBe('true') + expect(secondBefore.dataset.chatVisible).toBe('false') + expect(mocks.mountsByTabId).toEqual( + new Map([ + [FIRST_TAB_ID, 1], + [SECOND_TAB_ID, 1] + ]) + ) + + act(() => { + mocks.store?.setState({ + groupsByWorktree: { + [WORKTREE_ID]: [createGroup(SECOND_TAB_ID)] + } + }) + }) + + const firstAfter = chatSurface(view.container, FIRST_TAB_ID) + const secondAfter = chatSurface(view.container, SECOND_TAB_ID) + expect(firstAfter).toBe(firstBefore) + expect(secondAfter).toBe(secondBefore) + expect(firstAfter.dataset.chatVisible).toBe('false') + expect(secondAfter.dataset.chatVisible).toBe('true') + expect(mocks.mountsByTabId.get(FIRST_TAB_ID)).toBe(1) + expect(mocks.mountsByTabId.get(SECOND_TAB_ID)).toBe(1) + expect(mocks.unmountsByTabId.size).toBe(0) + }) + + it('routes overlay interaction back to the owning split group', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + fireEvent.pointerDown(slot!) + expect(mocks.focusGroup).toHaveBeenCalledWith(WORKTREE_ID, GROUP_ID) + }) + + it('keeps the base z-layer overridable by the working-chat stylesheet rule', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + expect(slot?.classList.contains('native-chat-pane-shell')).toBe(true) + expect(slot?.classList.contains('z-10')).toBe(true) + expect(slot?.style.zIndex).toBe('') + expect(slot?.querySelector('[data-native-chat-working="true"]')).not.toBeNull() + }) +}) + +function createState(activeTabId: string): MockAppState { + return { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + structuredTab(FIRST_TAB_ID, 'session-1', 0), + structuredTab(SECOND_TAB_ID, 'session-2', 1) + ] + }, + groupsByWorktree: { [WORKTREE_ID]: [createGroup(activeTabId)] }, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + } +} + +function createGroup(activeTabId: string): TabGroup { + return { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId, + tabOrder: [FIRST_TAB_ID, SECOND_TAB_ID] + } +} + +function structuredTab(id: string, sessionId: string, sortOrder: number): Tab { + return { + id, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +function chatSurface(container: HTMLElement, tabId: string): HTMLElement { + const surface = container.querySelector(`[data-chat-tab-id="${tabId}"]`) + if (!surface) { + throw new Error(`missing structured chat surface ${tabId}`) + } + return surface +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx new file mode 100644 index 00000000000..555d23b9ec4 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { useAppStore } from '@/store' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import NativeChatView from './NativeChatView' + +type StructuredAgentSessionTab = Tab & { + contentType: 'agent-session' + agentSessionAgent: NonNullable +} + +const EMPTY_UNIFIED_TABS: readonly Tab[] = [] +const EMPTY_GROUPS: readonly TabGroup[] = [] + +const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOverlaySlot({ + tab, + groupId, + isActive, + target, + allowFileUriLinks, + onFocusOwningGroup +}: { + tab: StructuredAgentSessionTab + groupId: string | undefined + isActive: boolean + target: RuntimeClientTarget + allowFileUriLinks: boolean + onFocusOwningGroup: ((groupId: string) => void) | undefined +}): React.JSX.Element { + const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined + const style = useMemo( + () => + anchorName + ? { + position: 'absolute', + positionAnchor: anchorName, + top: `anchor(${anchorName} top)`, + left: `anchor(${anchorName} left)`, + width: `anchor-size(${anchorName} width)`, + height: `anchor-size(${anchorName} height)`, + display: isActive ? 'flex' : 'none', + pointerEvents: isActive ? 'auto' : 'none' + } + : { display: 'none' }, + [anchorName, isActive] + ) + const focusOwningGroup = useCallback(() => { + if (groupId !== undefined && onFocusOwningGroup) { + onFocusOwningGroup(groupId) + } + }, [groupId, onFocusOwningGroup]) + + return ( +
+ +
+ ) +}) + +const StructuredAgentSessionPaneOverlayLayer = memo( + function StructuredAgentSessionPaneOverlayLayer({ + worktreeId, + isWorktreeActive + }: { + worktreeId: string + isWorktreeActive: boolean + }): React.JSX.Element { + const { unifiedTabs, groups, runtimeEnvironmentId, allowFileUriLinks } = useAppStore( + useShallow((state) => ({ + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), + allowFileUriLinks: getExecutionHostIdForWorktree(state, worktreeId) === 'local' + })) + ) + const focusGroup = useAppStore((state) => state.focusGroup) + const target = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId] + ) + const focusOwningGroup = useCallback( + (groupId: string) => focusGroup(worktreeId, groupId), + [focusGroup, worktreeId] + ) + const groupActiveTabById = useMemo( + () => new Map(groups.map((group) => [group.id, group.activeTabId] as const)), + [groups] + ) + const structuredTabs = useMemo( + () => + unifiedTabs.filter( + (tab): tab is StructuredAgentSessionTab => + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ), + [unifiedTabs] + ) + + return ( + <> + {structuredTabs.map((tab) => ( + + ))} + + ) + } +) + +export default StructuredAgentSessionPaneOverlayLayer diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx new file mode 100644 index 00000000000..aca391e238f --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + removeAgentStatus: vi.fn(), + setAgentStatus: vi.fn(), + store: null as null | { + getState: () => Record + setState: (state: Record) => void + }, + subscribe: vi.fn(), + unsubscribe: vi.fn() +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const useAppStore = create<{ + agentStatusByPaneKey: Record> + removeAgentStatus: (paneKey: string) => void + setAgentStatus: (...args: unknown[]) => void + testRuntimeOwner: string | null + unifiedTabsByWorktree: Record + }>((set, get) => ({ + agentStatusByPaneKey: {}, + removeAgentStatus: (paneKey) => { + mocks.removeAgentStatus(paneKey) + if (!get().agentStatusByPaneKey[paneKey]) { + return + } + const next = { ...get().agentStatusByPaneKey } + delete next[paneKey] + set({ agentStatusByPaneKey: next }) + }, + setAgentStatus: (...args) => { + mocks.setAgentStatus(...args) + const [paneKey, payload, terminalTitle, , routing, metadata] = args as [ + string, + Record, + string, + unknown, + Record, + Record + ] + set((state) => ({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + [paneKey]: { + ...payload, + ...routing, + ...metadata, + paneKey, + terminalTitle, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + })) + }, + testRuntimeOwner: null, + unifiedTabsByWorktree: {} + })) + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ?? null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' + +const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab + +const userItem = { + itemId: 'item-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } +} as const + +const historyResult = { + ok: true, + providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 1, + direction: 'tail', + items: [userItem], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 }, + hasOlder: false, + hasNewer: false + } +} + +function ActiveSessionRead(): null { + useStructuredAgentSessionRead({ + sessionId: structuredTab.entityId, + target: { kind: 'local' }, + isVisible: true + }) + return null +} + +function ActiveComposition(): React.JSX.Element { + return ( + <> + + + + ) +} + +describe('StructuredAgentSessionStatusBridge', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() + mocks.call.mockResolvedValue(historyResult) + mocks.subscribe.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionReadOwnersForTests() + }) + + it('keeps restored inactive tabs transport-neutral', async () => { + render() + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.setAgentStatus).not.toHaveBeenCalled() + }) + + it('shares the visible pane subscriber with status projection', async () => { + render() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + expect(mocks.call).toHaveBeenCalledOnce() + expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(mocks.setAgentStatus.mock.calls[0]?.[5]).toEqual({ + providerSession: historyResult.providerSession, + terminalResumeEligible: false + }) + }) + + it('keeps the status map reference stable for coalesced assistant deltas', async () => { + render() + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + const before = mocks.store?.getState().agentStatusByPaneKey + const onEvent = mocks.subscribe.mock.calls[0]?.[2] as (event: unknown) => void + + act(() => { + for (let sequence = 2; sequence <= 12; sequence += 1) { + onEvent({ + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'epoch-1', sequence }, + items: [ + { + itemId: 'assistant-1', + revision: sequence, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `delta-${sequence}` }] + } + } + ], + removedItemIds: [], + submissions: [] + } + }) + } + }) + await act(async () => new Promise((resolve) => setTimeout(resolve, 60))) + + expect(mocks.setAgentStatus).toHaveBeenCalledOnce() + expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) + }) + + it('does not project an unknown provider as Codex', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, agentSessionAgent: 'gemini' }] + } + }) + render() + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.setAgentStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx new file mode 100644 index 00000000000..cdd33499c7e --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import { + hasPersistedStructuredAgentSessionTurn, + projectStructuredAgentSessionStatus, + structuredAgentSessionPaneKey +} from '../../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import type { Tab } from '../../../../shared/tab-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionReadObservation } from './use-structured-agent-session-read' + +type StructuredTab = Tab & { contentType: 'agent-session' } + +function latestPrompt(state: StructuredAgentSessionState): string { + for (let index = state.items.length - 1; index >= 0; index -= 1) { + const body = state.items[index]?.body + if (body?.kind === 'message' && body.role === 'user') { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') + } + } + return '' +} + +function projectStatus( + tab: StructuredTab, + state: StructuredAgentSessionState, + providerSession: AgentProviderSessionMetadata | undefined +): void { + const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) + const store = useAppStore.getState() + if (!hasPersistedStructuredAgentSessionTurn(state.items)) { + if (store.agentStatusByPaneKey?.[paneKey]) { + store.removeAgentStatus(paneKey) + } + return + } + const projection = projectStructuredAgentSessionStatus(state.items) + const desired = { + state: projection === 'working' ? 'working' : projection === 'attention' ? 'blocked' : 'done', + prompt: latestPrompt(state), + agentType: tab.agentSessionAgent, + sessionBoundary: projection === 'idle' + } as const + const current = store.agentStatusByPaneKey?.[paneKey] + if ( + current?.state === desired.state && + current.prompt === desired.prompt && + current.agentType === desired.agentType && + current.sessionBoundary === desired.sessionBoundary && + current.terminalTitle === tab.label && + current.tabId === tab.id && + current.worktreeId === tab.worktreeId && + current.terminalResumeEligible === false && + agentProviderSessionsEqual(tab.agentSessionAgent, current.providerSession, providerSession) + ) { + return + } + store.setAgentStatus( + paneKey, + desired, + tab.label, + undefined, + { tabId: tab.id, worktreeId: tab.worktreeId }, + { + ...(providerSession ? { providerSession } : {}), + terminalResumeEligible: false + } + ) +} + +function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }): null { + const environmentId = useAppStore((state) => + getRuntimeEnvironmentIdForWorktree(state, tab.worktreeId) + ) + const target = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + [environmentId] + ) + const { providerSession, state } = useStructuredAgentSessionReadObservation({ + sessionId: tab.entityId, + target + }) + useEffect(() => { + projectStatus(tab, state, providerSession) + }, [providerSession, state, tab]) + useEffect( + () => () => + useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), + [tab.entityId, tab.id] + ) + return null +} + +export function StructuredAgentSessionStatusBridge(): React.JSX.Element { + const tabs = useAppStore( + useShallow((state) => + Object.values(state.unifiedTabsByWorktree) + .flat() + .filter( + (tab): tab is StructuredTab => + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ) + ) + ) + return ( + <> + {tabs.map((tab) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 3dde6373391..e90b7edcb44 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -25,6 +25,7 @@ vi.mock('@/i18n/i18n', () => ({ import { resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths } from './native-chat-attachment-upload' @@ -66,6 +67,28 @@ describe('resolveNativeChatAttachmentOwner', () => { expect(resolveNativeChatAttachmentOwner(state(), 'tab-1')).toEqual({ kind: 'local' }) }) + it('resolves a structured tab owner directly from its worktree', () => { + expect(resolveNativeChatAttachmentOwnerForWorktree(state(), 'wt-1')).toEqual({ + kind: 'local' + }) + }) + + it('resolves a structured SSH owner directly from its worktree', () => { + expect( + resolveNativeChatAttachmentOwnerForWorktree( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) + }), + 'wt-1' + ) + ).toMatchObject({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/repo/worktree' + }) + }) + it('resolves an SSH repo worktree to ssh with the worktree path', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 76b3b6471c8..8157a5190ff 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -58,6 +58,14 @@ export function resolveNativeChatAttachmentOwner( if (!worktreeId) { return { kind: 'not-ready' } } + return resolveNativeChatAttachmentOwnerForWorktree(state, worktreeId, terminalTabId) +} + +export function resolveNativeChatAttachmentOwnerForWorktree( + state: NativeChatAttachmentOwnerState, + worktreeId: string, + terminalTabId?: string +): NativeChatAttachmentOwner { if (getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { return { kind: 'runtime' } } @@ -68,7 +76,9 @@ export function resolveNativeChatAttachmentOwner( if (connectionId === null) { return { kind: 'local' } } - const worktreePath = resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + const worktreePath = terminalTabId + ? resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + : state.getKnownWorktreeById(worktreeId)?.path if (!worktreePath) { return { kind: 'not-ready' } } @@ -87,6 +97,13 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatLocalAttachmentUnsupportedNotice(): string { + return translate( + 'components.native-chat.composer.localAttachmentUnsupported', + 'Local attachments are not available for remote sessions.' + ) +} + /** * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH * remote and return the remote paths the agent can read (input order diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 58e97ccd209..45ebde1b375 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -1,5 +1,27 @@ import type { AgentType } from '../../../../shared/agent-status-types' +import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +export type NativeChatOptionPickerRequest = { + id: string + sequence: number +} + +export type NativeChatStructuredComposerTransport = { + send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean + dispatchCommand: (text: string) => Promise + optionsSurface: SessionOptionsSurface + optionSnapshot: SessionOptionDescriptor[] + optionPickerRequest?: NativeChatOptionPickerRequest | null + worktreeId?: string + onError: (message: string | null) => void + runtime: 'local' | 'remote' +} export type NativeChatComposerProps = { /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ @@ -29,6 +51,8 @@ export type NativeChatComposerProps = { launchDraft?: NativeChatLaunchDraft | null /** True once the transcript shows the TUI-side draft was submitted or cleared. */ launchDraftResolved?: boolean + /** Structured journal transport; absent keeps the existing PTY path unchanged. */ + structuredTransport?: NativeChatStructuredComposerTransport } export type NativeChatComposerHandle = { diff --git a/src/renderer/src/components/native-chat/native-chat-diff.test.ts b/src/renderer/src/components/native-chat/native-chat-diff.test.ts index 229edc1e4bd..7f8fa7af575 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.test.ts @@ -30,6 +30,39 @@ describe('diffFromToolCall', () => { ]) }) + it('reads structured apply_patch file changes as a diff', () => { + const diff = diffFromToolCall('apply_patch', { + changes: [ + { + path: '/repo/src/app.ts', + kind: { type: 'update', move_path: null }, + diff: '@@ -1,2 +1,2 @@\n const value = 1\n-old()\n+newValue()' + } + ] + }) + + expect(diff).toEqual([ + { kind: 'meta', text: '--- /repo/src/app.ts' }, + { kind: 'meta', text: '+++ /repo/src/app.ts' }, + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' const value = 1' }, + { kind: 'del', text: 'old()' }, + { kind: 'add', text: 'newValue()' } + ]) + }) + + it('reads direct apply_patch text as a diff', () => { + expect( + diffFromToolCall('apply_patch', { + patch: '@@ -1 +1 @@\n-before\n+after' + }) + ).toEqual([ + { kind: 'meta', text: '@@ -1 +1 @@' }, + { kind: 'del', text: 'before' }, + { kind: 'add', text: 'after' } + ]) + }) + it('returns null when there is no old/new payload', () => { expect(diffFromToolCall('Edit', { file_path: '/x' })).toBeNull() }) diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index 17dae96d98d..ab5d273ce54 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { @@ -32,6 +33,7 @@ function state(overrides: Partial = {}): AppState { tabsByWorktree: { 'wt-1': [terminalTab()] }, + unifiedTabsByWorktree: {}, worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/worktree' } as never] }, @@ -65,6 +67,33 @@ describe('resolveNativeChatFileLinkContext', () => { expect(resolveNativeChatFileLinkContext(state({ tabsByWorktree: {} }), 'tab-1')).toBeNull() }) + it('resolves the worktree context for a structured session tab', () => { + const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + + expect( + resolveNativeChatFileLinkContext( + state({ + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }), + structuredTab.id + ) + ).toEqual(context) + }) + it('falls back to repo-scoped worktrees when a known worktree has no path', () => { expect( resolveNativeChatFileLinkContext( diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index 44c1e83b6ab..c2d77d066c5 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -28,7 +28,9 @@ type NativeChatFileLinkState = Pick< | 'settings' | 'tabsByWorktree' | 'worktreesByRepo' -> +> & { + unifiedTabsByWorktree?: AppState['unifiedTabsByWorktree'] +} export function findTerminalTabWorktreeId( tabsByWorktree: NativeChatFileLinkState['tabsByWorktree'], @@ -44,6 +46,18 @@ export function findTerminalTabWorktreeId( return null } +function findStructuredTabWorktreeId( + unifiedTabsByWorktree: NativeChatFileLinkState['unifiedTabsByWorktree'], + tabId: string +): string | null { + for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) { + if (tabs.some((tab) => tab.id === tabId && tab.contentType === 'agent-session')) { + return worktreeId + } + } + return null +} + function findWorktreeFallback( worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'], worktreeId: string @@ -61,7 +75,9 @@ export function resolveNativeChatFileLinkContext( state: NativeChatFileLinkState, terminalTabId: string ): NativeChatFileLinkContext | null { - const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) + const worktreeId = + findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) ?? + findStructuredTabWorktreeId(state.unifiedTabsByWorktree, terminalTabId) if (!worktreeId) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index 3aead4c8dda..dc1ba79be3a 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -160,6 +160,33 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: null, exitChat: true }) }) + it('keeps structured chat open when its ownership transfer stops the TUI', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'adopted-agent', + activeLeafId: 'adopted-agent', + chatLeafStillMounted: false, + activeLeafIsEligible: false, + chatLeafHasConfirmedAgentExit: true, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + + it('binds tab-bar structured adoption to the active leaf after the TUI exits', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'adopted-agent', + chatLeafStillMounted: true, + activeLeafIsEligible: false, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + it('moves chat to an eligible sibling after the owning agent exits', () => { expect( resolveNativeChatLeafRoute({ diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 2dfc25e77f8..59c660930c5 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -73,11 +73,19 @@ export function resolveNativeChatLeafRoute(args: { chatLeafStillMounted: boolean activeLeafIsEligible: boolean chatLeafHasConfirmedAgentExit?: boolean + structuredSessionId?: string | null }): NativeChatLeafRoute { + const confirmedAgentExit = args.chatLeafHasConfirmedAgentExit && !args.structuredSessionId if (!args.isChatViewMode) { return { chatLeafId: null, exitChat: false } } - if (args.chatLeafId && args.chatLeafStillMounted && !args.chatLeafHasConfirmedAgentExit) { + if (args.structuredSessionId) { + return { + chatLeafId: args.chatLeafId ?? args.activeLeafId, + exitChat: false + } + } + if (args.chatLeafId && args.chatLeafStillMounted && !confirmedAgentExit) { // Why: agent/title evidence can disappear while local, SSH, or runtime // transports reconnect. A mounted owning pane is not a terminal lifecycle // event, so keep its chat surface until the pane itself is removed. @@ -85,13 +93,10 @@ export function resolveNativeChatLeafRoute(args: { } // Manager hydration can briefly have no active pane; preserve the requested // mode until a concrete leaf exists instead of toggling it off during mount. - if (!args.activeLeafId && !args.chatLeafHasConfirmedAgentExit) { + if (!args.activeLeafId && !confirmedAgentExit) { return { chatLeafId: args.chatLeafId, exitChat: false } } - if ( - args.activeLeafIsEligible && - (!args.chatLeafHasConfirmedAgentExit || args.activeLeafId !== args.chatLeafId) - ) { + if (args.activeLeafIsEligible && (!confirmedAgentExit || args.activeLeafId !== args.chatLeafId)) { return { chatLeafId: args.activeLeafId, exitChat: false } } // Why: removing the owning leaf or confirming its agent exited must not leave diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index 17e05af6a5c..b0f59a8a8a1 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -20,6 +20,7 @@ export type NativeChatSkillStateInputs = Pick< | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' | 'tabsByWorktree' + | 'unifiedTabsByWorktree' | 'worktreesByRepo' > @@ -27,6 +28,7 @@ type NativeChatSkillTab = { id: string; startupCwd?: string } type NativeChatSkillWorktreeState = { tabsByWorktree: Record + unifiedTabsByWorktree?: Record worktreesByRepo: Record } @@ -49,6 +51,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktreesByRepo: state.worktreesByRepo } } @@ -57,7 +60,7 @@ export function resolveNativeChatSkillDiscoveryCwd( state: NativeChatSkillWorktreeState, terminalTabId: string ): string | null { - const found = findTerminalTab(state.tabsByWorktree, terminalTabId) + const found = findNativeChatTab(state, terminalTabId) if (!found) { return null } @@ -80,7 +83,7 @@ export function resolveNativeChatSkillDiscoveryContext( state: NativeChatSkillStateInputs, terminalTabId: string ): NativeChatSkillDiscoveryContext | null { - const worktreeId = findTerminalTab(state.tabsByWorktree, terminalTabId)?.worktreeId ?? null + const worktreeId = findNativeChatTab(state, terminalTabId)?.worktreeId ?? null if (!worktreeId) { return null } @@ -142,6 +145,16 @@ export function resolveNativeChatSkillDiscoveryContext( } } +function findNativeChatTab( + state: Pick, + tabId: string +): { worktreeId: string; tab: NativeChatSkillTab } | null { + return ( + findTerminalTab(state.tabsByWorktree, tabId) ?? + findTerminalTab(state.unifiedTabsByWorktree ?? {}, tabId) + ) +} + function findTerminalTab( tabsByWorktree: Record, terminalTabId: string diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts new file mode 100644 index 00000000000..4b18921f221 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(path: string): string { + return readFileSync(join(process.cwd(), path), 'utf8') +} + +describe('native chat Stop layering', () => { + it('keeps a working chat pane above bottom-right product chrome', () => { + const css = source('src/renderer/src/assets/main.css') + const terminalPane = source('src/renderer/src/components/terminal-pane/TerminalPane.tsx') + + expect(terminalPane).toContain('native-chat-pane-shell absolute inset-0 z-10') + expect(css).toMatch(/\[data-sonner-toaster\][^{]*\{[^}]*z-index:\s*40\s*!important;/s) + expect(css).toMatch( + /\.native-chat-pane-shell:has\(\[data-native-chat-working='true'\]\)[^{]*\{[^}]*z-index:\s*50;/s + ) + }) + + it('publishes working state from both structured and bridge chat roots', () => { + for (const path of [ + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', + 'src/renderer/src/components/native-chat/NativeChatView.tsx' + ]) { + expect(source(path)).toContain('data-native-chat-working=') + } + }) + + it('owns structured session panes at the retained worktree overlay layer', () => { + const terminal = source('src/renderer/src/components/Terminal.tsx') + const tabGroup = source('src/renderer/src/components/tab-group/TabGroupPanel.tsx') + + expect(terminal).toContain(' { + const command = await transport.dispatchCommand(text) + if (command.handled) { + return { accepted: command.accepted, error: command.error } + } + return { accepted: transport.send(text, attachments), error: null } +} diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts new file mode 100644 index 00000000000..db572e57364 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' + +function message(id: string, role: NativeChatMessage['role'], text = id): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text }], timestamp: null, source: 'transcript' } +} + +describe('shouldShowNativeChatTypingIndicator', () => { + it('stays hidden when the session is idle', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user')], + isWorking: false + }) + ).toBe(false) + }) + + it('shows once a send lands and no assistant row exists yet', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('a0', 'assistant'), message('u1', 'user')], + isWorking: true + }) + ).toBe(true) + }) + + it('hides as soon as the structured reply row arrives, before working clears', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message('orca-item', 'assistant')], + isWorking: true + }) + ).toBe(false) + }) + + it('hides behind the PTY streaming bubble', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message(NATIVE_CHAT_STREAMING_ID, 'assistant')], + isWorking: true + }) + ).toBe(false) + }) + + it('does not flicker back on when a system row interleaves mid-turn', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [ + message('u1', 'user'), + message('a1', 'assistant'), + message('s1', 'system', 'Ran /status') + ], + isWorking: true + }) + ).toBe(false) + }) + + it('shows again for the next send even though an earlier turn replied', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message('a1', 'assistant'), message('u2', 'user')], + isWorking: true + }) + ).toBe(true) + }) + + it('shows after a slash-command marker even though an earlier turn replied', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [ + message('a1', 'assistant'), + message('command:compact', 'system', 'Ran /compact') + ], + isWorking: true + }) + ).toBe(true) + }) + + it('shows on a session whose transcript is still empty', () => { + expect(shouldShowNativeChatTypingIndicator({ messages: [], isWorking: true })).toBe(true) + }) +}) + +// These build rows through the REAL structured projection instead of hand-made +// `command:` marker ids. The hand-made ids only exist on the PTY transport, so +// tests using them were blind to how the shipping transport actually looks. +describe('with rows projected from the structured journal', () => { + function toolCallItem(sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:thread-1:turn-1:${sequence}`, + revision: 1, + sequence, + observedAt: 1_800_000_000_000, + body: { + kind: 'tool-call', + name: 'shell', + state: 'running', + input: { command: 'sed -n 1,240p README.md' } + } + } as AgentJournalRenderItem + } + + function assistantTextItem(sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:thread-1:turn-1:${sequence}`, + revision: 1, + sequence, + observedAt: 1_800_000_000_000, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: "I'm checking PR 14696's metadata." }] + } + } as AgentJournalRenderItem + } + + it('keeps showing while a running command is the newest row', () => { + // The screenshot case: prose landed, then codex started running shell commands + // and the chat body went still for the length of the command. + const messages = projectStructuredItemsToNativeChat([assistantTextItem(1), toolCallItem(2)]) + expect(messages.at(-1)?.role).toBe('assistant') + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(true) + }) + + it('still hides once prose is the newest row', () => { + const messages = projectStructuredItemsToNativeChat([toolCallItem(1), assistantTextItem(2)]) + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(false) + }) + + it('stays hidden when the turn is not working, command row or not', () => { + const messages = projectStructuredItemsToNativeChat([toolCallItem(1)]) + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts new file mode 100644 index 00000000000..a3574e1842c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts @@ -0,0 +1,54 @@ +// When the trailing "…" row is allowed to render. +// +// The rule suppresses the dots once the turn's own assistant ANSWER is on screen, +// because a placeholder below streamed text reflows the list when it disappears. +// It must not suppress on a row that only reports tool work: a shell command can +// run for a minute with nothing else arriving, and that is precisely when the +// user needs to see that the turn is still alive. +// +// Both transports have to agree, and matching on `role` alone does not get there: +// the PTY path emits synthetic `command:` marker rows, while the structured path +// projects a journal tool-call item as `role: 'assistant'` with tool blocks. Same +// meaning, different shape — so the predicate is about the row's CONTENT. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import { isCommandMarkerId } from './native-chat-command-marker' + +/** A row carrying only tool activity — no prose. It is progress, not an answer. */ +function isToolActivityOnlyRow(message: NativeChatMessage): boolean { + const blocks = message.blocks + if (!blocks || blocks.length === 0) { + return false + } + return blocks.every((block) => block.type === 'tool-call' || block.type === 'tool-result') +} + +export function shouldShowNativeChatTypingIndicator(args: { + messages: readonly NativeChatMessage[] + isWorking: boolean +}): boolean { + if (!args.isWorking) { + return false + } + const { messages } = args + // Scan back only to the turn boundary: an assistant row from an EARLIER turn + // must not suppress the indicator for the send the user just made. + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message || message.role === 'user' || isCommandMarkerId(message.id)) { + return true + } + // Tool work is the strongest reason to KEEP the dots, so it decides here + // rather than falling through to the assistant-role check below. + if (isToolActivityOnlyRow(message)) { + return true + } + // Status/system rows interleave mid-turn; they neither suppress nor unsuppress, + // otherwise the dots would flicker back on between assistant chunks. + if (message.role === 'assistant' || message.id === NATIVE_CHAT_STREAMING_ID) { + return false + } + } + return true +} diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index de095f9b232..4dd9a7bac03 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,8 +1,18 @@ +import type { + AgentStatusOrchestrationContext, + AgentType +} from '../../../../shared/agent-status-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { NativeChatSession } from '../../../../shared/native-chat-types' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -export type NativeChatViewProps = { +type NativeChatOrchestrationProps = { + orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] +} + +export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { + mode?: 'bridge' /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ terminalTabId: string /** Whether the hosted terminal surface is currently visible. */ @@ -22,7 +32,17 @@ export type NativeChatViewProps = { contextMenuActions?: Omit } -export type NativeChatResolvedViewProps = { +export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { + mode: 'structured' + tabId: string + sessionId: string + target: RuntimeClientTarget + agent: AgentType + isVisible: boolean + allowFileUriLinks: boolean +} + +export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & { paneKey: string agent: NativeChatSession['agent'] sessionId: string | null @@ -34,3 +54,5 @@ export type NativeChatResolvedViewProps = { readTerminalScreen?: () => string | null contextMenuActions?: Omit } + +export type NativeChatViewProps = NativeChatBridgeViewProps | NativeChatStructuredViewProps diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts new file mode 100644 index 00000000000..745aa5b4314 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +function submission(index: number): AgentJournalSubmission { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted', + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function item(index: number): AgentJournalRenderItem { + return { + itemId: `journal-${index}`, + revision: 1, + sequence: index, + observedAt: index, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `send ${index}` }] } + } +} + +describe('structured agent session message projection', () => { + it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => { + const outbox = Array.from({ length: sendCount }, (_, index) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId: `client-${index}`, + sessionId: 'session-1', + text: `send ${index}`, + attachments: [], + queuedAt: index + }) + ) + const messages = projectStructuredAgentSessionMessages( + Array.from({ length: sendCount }, (_, index) => item(index)), + outbox, + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) + ) + + expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) + expect(messages.map((message) => message.id)).toEqual( + Array.from({ length: sendCount }, (_, index) => `journal-${index}`) + ) + }) + + it('renders one bubble while the submission is still dispatching', () => { + const outbox = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-pending', + sessionId: 'session-1', + text: 'Ok thanks', + attachments: [], + queuedAt: 1 + }) + ] + // The host's WAL row is on screen while the provider round trip is in flight. + const walItem: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('client-pending'), + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ok thanks' }] } + } + const pending: AgentJournalSubmission = { + ...submission(0), + clientMessageId: 'client-pending', + dispatchState: 'pending', + providerItemId: null, + resolvedAt: null + } + + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) + const optimistic = projectStructuredAgentSessionMessages([], outbox, []) + + expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) + expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) + expect(optimistic[0]?.id).toBe(messages[0]?.id) + }) + + it('keeps an optimistic send until its acceptance arrives', () => { + const outbox = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-pending', + sessionId: 'session-1', + text: 'pending', + attachments: [], + queuedAt: 1 + }) + ] + + expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ + { id: agentJournalSubmissionKey('client-pending'), role: 'user' } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts new file mode 100644 index 00000000000..9da10c6581a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -0,0 +1,38 @@ +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' + +export function projectStructuredAgentSessionMessages( + items: readonly AgentJournalRenderItem[], + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): NativeChatMessage[] { + const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) + // Why: the host renders its own bubble off the submission WAL row, which lands + // while the dispatch is still `pending`. Reconciliation only retires the echo on + // `accepted`, so keying visibility on that alone double-rendered the bubble for + // the whole provider round trip. The entry itself stays for retry/unconfirmed. + const journalled = new Set(items.map((item) => item.itemId)) + return [ + ...projectStructuredItemsToNativeChat(items), + ...optimistic + .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) + .map( + (entry): NativeChatMessage => ({ + id: agentJournalSubmissionKey(entry.clientMessageId), + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks + }) + ) + ] +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts new file mode 100644 index 00000000000..4c43326acb1 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -0,0 +1,272 @@ +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + oldestStructuredAgentSessionCursor, + reduceStructuredAgentSession, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { NATIVE_CHAT_INITIAL_LIMIT } from './native-chat-pagination' +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +export type StructuredAgentSessionReadSnapshot = { + state: StructuredAgentSessionState + loadingOlder: boolean + providerSession?: AgentProviderSessionMetadata +} + +export type StructuredAgentSessionReadOwner = { + activate: () => () => void + dispose: () => void + getSnapshot: () => StructuredAgentSessionReadSnapshot + loadOlder: () => Promise + refresh: () => void + subscribe: (listener: () => void) => () => void +} + +const owners = new Map() + +function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { + return item.body.kind !== 'status' || !item.body.providerFrame +} + +function ownerKey(sessionId: string, target: RuntimeClientTarget): string { + const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` + return `${targetKey}:${sessionId}` +} + +function createReadOwner( + key: string, + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionReadOwner { + let snapshot: StructuredAgentSessionReadSnapshot = { + state: EMPTY_STRUCTURED_AGENT_SESSION, + loadingOlder: false + } + let stopActiveRun: (() => void) | null = null + let refreshActiveRun = (): void => {} + const retiredHistoryRead = (): boolean => true + let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead + const activations = new Set() + const listeners = new Set<() => void>() + + const emit = (): void => { + for (const listener of listeners) { + listener() + } + } + const setSnapshot = (next: StructuredAgentSessionReadSnapshot): void => { + if (next === snapshot) { + return + } + snapshot = next + emit() + } + const apply = (action: StructuredAgentSessionAction): void => { + const state = reduceStructuredAgentSession(snapshot.state, action) + if (state !== snapshot.state) { + setSnapshot({ ...snapshot, state }) + } + } + const setProviderSession = (providerSession: AgentProviderSessionMetadata | undefined): void => { + if (!agentProviderSessionsEqual(undefined, snapshot.providerSession, providerSession)) { + setSnapshot({ ...snapshot, providerSession }) + } + } + const clearLoadingOlder = (): void => { + if (snapshot.loadingOlder) { + setSnapshot({ ...snapshot, loadingOlder: false }) + } + } + const refreshTail = async (shouldStop: () => boolean): Promise => { + const result = await callStructuredAgentSession( + target, + 'agentSession.history', + { sessionId, direction: 'tail', limit: AGENT_SESSION_HISTORY_MAX_LIMIT } + ) + if (shouldStop()) { + return + } + setProviderSession(result.providerSession) + if (!result.ok) { + if (shouldStop()) { + return + } + apply({ + type: 'event', + event: { + type: 'reset', + sessionId, + reset: result.reset, + page: result.page, + fence: result.fence ?? 0 + } + }) + return + } + if (shouldStop()) { + return + } + apply({ type: 'tail-page', page: result.page }) + if (shouldStop()) { + return + } + let restored = snapshot.state.items.filter(countsTowardInitialHistory).length + while (snapshot.state.hasOlder && restored < NATIVE_CHAT_INITIAL_LIMIT) { + const oldest = oldestStructuredAgentSessionCursor(snapshot.state) + if (!oldest || shouldStop()) { + break + } + const missing = NATIVE_CHAT_INITIAL_LIMIT - restored + const older = await callStructuredAgentSession( + target, + 'agentSession.history', + { + sessionId, + direction: 'before', + cursor: oldest, + limit: Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, missing) + } + ) + if (shouldStop()) { + return + } + if (!older.ok || older.page.window.oldest?.sequence === oldest.sequence) { + break + } + if (shouldStop()) { + return + } + apply({ type: 'older-page', requestedEpoch: oldest.epoch, page: older.page }) + if (shouldStop()) { + return + } + restored = snapshot.state.items.filter(countsTowardInitialHistory).length + } + } + + const start = (): void => { + if (snapshot.state.epoch === null) { + apply({ type: 'loading' }) + } + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: (event) => apply({ type: 'event', event }), + applyError: (message) => apply({ type: 'error', message }), + getCursor: () => snapshot.state.cursor, + onHistoryReadInvalidated: clearLoadingOlder, + refreshTail, + sessionId, + target + }) + captureActiveHistoryReadGuard = transport.captureHistoryReadGuard + refreshActiveRun = transport.refresh + stopActiveRun = () => { + captureActiveHistoryReadGuard = () => retiredHistoryRead + refreshActiveRun = (): void => {} + transport.dispose() + stopActiveRun = null + } + } + + let owner: StructuredAgentSessionReadOwner + const deleteIfUnused = (): void => { + if (activations.size === 0 && listeners.size === 0 && owners.get(key) === owner) { + owners.delete(key) + } + } + owner = { + activate: () => { + const token = Symbol(sessionId) + activations.add(token) + if (activations.size === 1) { + start() + } + return () => { + activations.delete(token) + if (activations.size === 0) { + stopActiveRun?.() + deleteIfUnused() + } + } + }, + dispose: () => { + activations.clear() + listeners.clear() + stopActiveRun?.() + }, + getSnapshot: () => snapshot, + loadOlder: async () => { + const shouldStop = captureActiveHistoryReadGuard() + if (shouldStop()) { + return + } + const cursor = oldestStructuredAgentSessionCursor(snapshot.state) + if (!cursor || !snapshot.state.hasOlder || snapshot.loadingOlder) { + return + } + if (shouldStop()) { + return + } + setSnapshot({ ...snapshot, loadingOlder: true }) + try { + const result = await callStructuredAgentSession( + target, + 'agentSession.history', + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } + ) + if (shouldStop()) { + return + } + if (result.ok && !shouldStop()) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + } catch (error) { + if (!shouldStop()) { + apply({ type: 'error', message: String(error) }) + } + } finally { + if (!shouldStop()) { + clearLoadingOlder() + } + } + }, + refresh: () => refreshActiveRun(), + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + deleteIfUnused() + } + } + } + return owner +} + +export function getStructuredAgentSessionReadOwner( + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionReadOwner { + const key = ownerKey(sessionId, target) + let owner = owners.get(key) + if (!owner) { + owner = createReadOwner(key, sessionId, target) + owners.set(key, owner) + } + return owner +} + +export function resetStructuredAgentSessionReadOwnersForTests(): void { + for (const owner of owners.values()) { + owner.dispose() + } + owners.clear() +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts new file mode 100644 index 00000000000..b420cc5b529 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +type SubscribeAttempt = { + closed: PromiseWithResolvers<{ unsubscribe: () => void }> + onClose: () => void + onError: (error: unknown) => void + onEvent: (event: AgentSessionSubscribeEvent) => void + unsubscribe: ReturnType void>> +} + +const target = { kind: 'local' } as const + +function snapshot(sequence: number): AgentSessionSubscribeEvent { + const cursor: AgentJournalCursor = { epoch: 'epoch-a', sequence } + const page: AgentSessionHistoryPage = { + sessionId: 'session-a', + epoch: cursor.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false + } + return { type: 'snapshot', sessionId: 'session-a', page, fence: sequence } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('structured agent-session read transport generations', () => { + const attempts: SubscribeAttempt[] = [] + + beforeEach(() => { + attempts.length = 0 + vi.clearAllMocks() + mocks.subscribe.mockImplementation((_target, _params, onEvent, onError, onClose) => { + const attempt: SubscribeAttempt = { + closed: Promise.withResolvers<{ unsubscribe: () => void }>(), + onClose, + onError, + onEvent, + unsubscribe: vi.fn<() => void>() + } + attempts.push(attempt) + return attempt.closed.promise + }) + }) + + function start(applyEvent: (event: AgentSessionSubscribeEvent) => void, applyError = vi.fn()) { + return startStructuredAgentSessionReadTransport({ + applyEvent, + applyError, + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + refreshTail: async () => undefined, + sessionId: 'session-a', + target + }) + } + + it('ignores opening frames after disposal and a replacement transport starts', async () => { + const applyEvent = vi.fn() + const applyError = vi.fn() + const retired = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) + + retired.dispose() + const replacement = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('retired error')) + attempts[0].onClose() + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(attempts[0].unsubscribe).toHaveBeenCalledOnce() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + replacement.dispose() + }) + + it('ignores callbacks from a subscription superseded by reconnect', async () => { + vi.useFakeTimers() + try { + const applyEvent = vi.fn() + const applyError = vi.fn() + const transport = start(applyEvent, applyError) + await flushPromises() + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + + attempts[0].onClose() + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('stale error')) + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + transport.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts new file mode 100644 index 00000000000..79232a4ead7 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -0,0 +1,210 @@ +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' +import { shouldAdvanceStructuredResumeCursor } from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { + let timer: ReturnType | null = null + return { + schedule(delay = 750): void { + if (args.shouldStop() || timer) { + return + } + timer = setTimeout(() => { + timer = null + if (!args.shouldStop()) { + args.reconnect() + } + }, delay) + }, + dispose(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} + +export function startStructuredAgentSessionReadTransport(args: { + applyEvent: (event: AgentSessionSubscribeEvent) => void + applyError: (message: string) => void + getCursor: () => AgentJournalCursor | null + onHistoryReadInvalidated: () => void + refreshTail: (shouldStop: () => boolean) => Promise + sessionId: string + target: RuntimeClientTarget +}): { + captureHistoryReadGuard: () => () => boolean + dispose: () => void + refresh: () => void +} { + let stopped = false + let connected = false + let opening = false + let openGeneration = 0 + let stateGeneration = 0 + let unsubscribe = (): void => {} + let resumeCursor = args.getCursor() + let shouldStopCoalescedEvent = (): boolean => true + const coalescer = createStructuredAgentSessionEventCoalescer((event) => { + if (!shouldStopCoalescedEvent()) { + args.applyEvent(event) + } + }) + const reconnectScheduler = createReconnectScheduler({ + shouldStop: () => stopped || connected, + reconnect: () => void open() + }) + const isCurrentOpenGeneration = (candidate: number): boolean => + !stopped && candidate === openGeneration + const captureHistoryReadGuard = (): (() => boolean) => { + const readOpenGeneration = openGeneration + const readStateGeneration = stateGeneration + return () => + !isCurrentOpenGeneration(readOpenGeneration) || readStateGeneration !== stateGeneration + } + const handleEvent = (event: AgentSessionSubscribeEvent, eventOpenGeneration: number): void => { + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + if (event.type === 'snapshot' || event.type === 'reset') { + coalescer.flush() + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + stateGeneration += 1 + args.onHistoryReadInvalidated() + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + resumeCursor = event.page.liveCursor ?? event.page.window.nextCursor + } else if ( + event.type === 'batch' && + shouldAdvanceStructuredResumeCursor(resumeCursor, event.batch.cursor) + ) { + resumeCursor = event.batch.cursor + } else if (event.type === 'end') { + connected = false + reconnectScheduler.schedule() + } + shouldStopCoalescedEvent = captureHistoryReadGuard() + coalescer.push(event) + } + async function open(): Promise { + if (stopped || connected) { + return + } + if (opening) { + reconnectScheduler.schedule() + return + } + opening = true + coalescer.flush() + if (stopped) { + opening = false + return + } + const currentOpenGeneration = ++openGeneration + args.onHistoryReadInvalidated() + unsubscribe() + unsubscribe = (): void => {} + try { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + let closedDuringOpen = false + const handle = await subscribeStructuredAgentSession( + args.target, + { sessionId: args.sessionId, ...(resumeCursor ? { cursor: resumeCursor } : {}) }, + (event) => handleEvent(event, currentOpenGeneration), + (error) => { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + closedDuringOpen = true + connected = false + args.applyError(String(error)) + reconnectScheduler.schedule() + }, + () => { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + closedDuringOpen = true + connected = false + reconnectScheduler.schedule() + } + ) + if (!isCurrentOpenGeneration(currentOpenGeneration) || closedDuringOpen) { + handle.unsubscribe() + if (isCurrentOpenGeneration(currentOpenGeneration)) { + reconnectScheduler.schedule() + } + } else { + connected = true + unsubscribe = handle.unsubscribe + } + } catch (error) { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + connected = false + args.applyError(String(error)) + reconnectScheduler.schedule() + } finally { + if (currentOpenGeneration === openGeneration) { + opening = false + } + } + } + const refresh = (): void => { + const shouldStop = captureHistoryReadGuard() + void args + .refreshTail(shouldStop) + .then(() => { + if (shouldStop()) { + return + } + resumeCursor = args.getCursor() + if (!connected) { + reconnectScheduler.schedule(0) + } + }) + .catch((error) => { + if (!shouldStop()) { + args.applyError(String(error)) + } + }) + } + const shouldStopInitialRead = captureHistoryReadGuard() + void args + .refreshTail(shouldStopInitialRead) + .then(() => { + if (shouldStopInitialRead()) { + return + } + resumeCursor = args.getCursor() + return open() + }) + .catch((error) => { + if (!shouldStopInitialRead()) { + args.applyError(String(error)) + reconnectScheduler.schedule() + } + }) + return { + captureHistoryReadGuard, + dispose: () => { + stopped = true + openGeneration += 1 + args.onHistoryReadInvalidated() + reconnectScheduler.dispose() + coalescer.dispose() + unsubscribe() + }, + refresh + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 82d788e94f6..06b61d9610b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -25,9 +25,11 @@ const target: NativeChatResolvedTarget = { function Probe({ scopeKey, + structured = false, onReady }: { scopeKey: string + structured?: boolean onReady: (api: ProbeApi) => void }): React.JSX.Element { const [caret, setCaret] = useState(0) @@ -36,8 +38,9 @@ function Probe({ const textareaRef = useRef(null) const api = useNativeChatComposerAttachments({ attachmentScopeKey: scopeKey, + allowWithoutTarget: structured, caret, - resolveTarget: () => target, + resolveTarget: () => (structured ? null : target), textareaRef, setCaret, setDraft: (updater) => setDraftValue((previous) => updater(previous)), @@ -48,7 +51,8 @@ function Probe({ } async function renderProbe( - scopeKey: string + scopeKey: string, + structured = false ): Promise<{ root: Root; latest: () => ProbeApi; rerender: (scopeKey: string) => Promise }> { const container = document.createElement('div') document.body.append(container) @@ -60,7 +64,7 @@ async function renderProbe( api = next } await act(async () => { - root.render(createElement(Probe, { scopeKey, onReady })) + root.render(createElement(Probe, { scopeKey, structured, onReady })) }) if (!api) { throw new Error('Probe did not render') @@ -75,7 +79,7 @@ async function renderProbe( }, rerender: async (nextScopeKey: string) => { await act(async () => { - root.render(createElement(Probe, { scopeKey: nextScopeKey, onReady })) + root.render(createElement(Probe, { scopeKey: nextScopeKey, structured, onReady })) }) } } @@ -112,6 +116,17 @@ describe('useNativeChatComposerAttachments', () => { act(() => second.root.unmount()) }) + it('accepts host-readable image paths without a PTY for structured transport', async () => { + const probe = await renderProbe('structured-session-1', true) + + await act(async () => { + probe.latest().attachResolvedPaths(['/tmp/structured-image.png']) + }) + + expect(probe.latest().imageAttachments).toMatchObject([{ path: '/tmp/structured-image.png' }]) + act(() => probe.root.unmount()) + }) + it('removes an attached image chip cleanly', async () => { const probe = await renderProbe('pty-1') await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 60ecf44ff5a..b9d94d8e253 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -11,6 +11,7 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + allowWithoutTarget?: boolean caret: number resolveTarget: () => NativeChatResolvedTarget | null textareaRef: RefObject @@ -21,6 +22,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + allowWithoutTarget = false, caret, resolveTarget, textareaRef, @@ -107,7 +109,10 @@ export function useNativeChatComposerAttachments({ const attachResolvedPaths = useCallback( (paths: string[]) => { const target = resolveTarget() - if (!target || nativeChatComposerTargetIsRemote(target.ptyId)) { + if ( + (!target && !allowWithoutTarget) || + (target && nativeChatComposerTargetIsRemote(target.ptyId)) + ) { setNotice( translate( 'components.native-chat.composer.localAttachmentUnsupported', @@ -128,7 +133,14 @@ export function useNativeChatComposerAttachments({ requestAnimationFrame(() => textareaRef.current?.focus()) } }, - [appendImageAttachments, insertFileReferences, resolveTarget, setNotice, textareaRef] + [ + allowWithoutTarget, + appendImageAttachments, + insertFileReferences, + resolveTarget, + setNotice, + textareaRef + ] ) return { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 158ab0bfa13..7f5d0e2f1b9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -18,6 +18,8 @@ vi.mock('./native-chat-composer-target', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' })) @@ -133,6 +135,24 @@ afterEach(() => { }) describe('useNativeChatComposerPaste', () => { + it('does not save a clipboard image locally for a remote runtime', async () => { + const setNotice = vi.fn() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => ({ kind: 'runtime' }), + attachResolvedPaths, + setNotice + }) + + await act(async () => probe.latest().pasteFromClipboard()) + + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('surfaces a failed SSH image save through the composer notice', async () => { mocks.saveClipboardImageAsTempFile.mockRejectedValue( new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index dbb6a06ed03..c92c071209e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -5,6 +5,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { resolveImagePaste } from './native-chat-image-paste' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' @@ -73,6 +74,10 @@ export function useNativeChatComposerPaste({ async ( owner: NativeChatAttachmentOwner ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return { status: 'failed' } + } try { // SSH panes save the image on the remote host (SFTP) so the attached // path is readable by the remote agent, matching terminal image paste. diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 61d2b321fb8..9aed41a0cdc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useMemo, useRef, useState, type MouseEventHandler, @@ -18,7 +17,6 @@ import { PanelsTopLeft, PanelRightClose, Pencil, - SquareTerminal, X } from 'lucide-react' import { @@ -30,7 +28,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' -import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' +import { isMacPlatform } from './native-chat-shortcut' type NativeChatContextMenuState = { open: boolean @@ -40,7 +38,6 @@ type NativeChatContextMenuState = { type UseNativeChatContextMenuArgs = { rootRef: RefObject - onSwitchToTerminal?: () => void actions: NativeChatContextMenuActions } @@ -82,11 +79,7 @@ export const emptyNativeChatContextMenuActions: Omit {} } -export function useNativeChatContextMenu({ - rootRef, - onSwitchToTerminal, - actions -}: UseNativeChatContextMenuArgs): { +export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatContextMenuArgs): { onContextMenuCapture: MouseEventHandler onSelectionCapture: () => void menu: React.JSX.Element @@ -98,7 +91,6 @@ export function useNativeChatContextMenu({ point: { x: 0, y: 0 }, selectedText: '' }) - const shortcutLabel = useMemo(() => nativeChatToggleShortcutLabel(isMacPlatform()), []) const rememberCurrentSelection = useCallback(() => { const selectedText = getNativeChatSelectedText(rootRef.current) @@ -165,16 +157,6 @@ export function useNativeChatContextMenu({ {translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')} - {onSwitchToTerminal ? ( - - - {translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - )} - {shortcutLabel} - - ) : null} {actions.canContinueAgentSessionInNewSession ? ( diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index cc2dc4c9264..3d9017a0c8a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -13,6 +13,8 @@ vi.mock('@/store', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' @@ -137,6 +139,20 @@ describe('useNativeChatExternalAttachments', () => { expect(attachResolvedPaths).not.toHaveBeenCalled() }) + it('does not attach client-local paths to a remote runtime', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' }) + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ attachResolvedPaths, setNotice }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('drops an upload that resolves after the composer became disabled', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'ssh', diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index d2e19375eec..25794e4c128 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -1,14 +1,17 @@ import { useCallback, useRef } from 'react' import { useAppStore } from '@/store' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string + structuredWorktreeId?: string /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean @@ -23,6 +26,7 @@ export type UseNativeChatExternalAttachmentsArgs = { */ export function useNativeChatExternalAttachments({ terminalTabId, + structuredWorktreeId, disabled, attachResolvedPaths, setNotice @@ -34,8 +38,11 @@ export function useNativeChatExternalAttachments({ disabledRef.current = disabled const resolveAttachmentOwner = useCallback( - () => resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), - [terminalTabId] + () => + structuredWorktreeId + ? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId) + : resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), + [structuredWorktreeId, terminalTabId] ) const attachExternalPaths = useCallback( @@ -48,9 +55,11 @@ export function useNativeChatExternalAttachments({ setNotice(nativeChatWorktreeNotReadyNotice()) return } + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return + } if (owner.kind !== 'ssh') { - // 'runtime' proceeds so attachResolvedPaths' existing remote-session - // gate reports the unsupported state. attachResolvedPaths(paths) return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts new file mode 100644 index 00000000000..d3073115b5a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts @@ -0,0 +1,7 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../../store' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkContext(terminalTabId: string) { + return useAppStore(useShallow((state) => resolveNativeChatFileLinkContext(state, terminalTabId))) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts new file mode 100644 index 00000000000..333109748fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -0,0 +1,112 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import { useAppStore } from '../../store' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + sendNativeChatMessage, + sendNativeChatTypedCommand, + submitNativeChatPrompt +} from './native-chat-runtime-send' +import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' +import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' +import { nativeChatComposerTargetIsRemote } from './native-chat-composer-target' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' +import type { NativeChatPickerState } from './use-native-chat-picker-state' +import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +export function useNativeChatPtyComposerSend(args: { + agent: AgentType + draft: string + imageAttachments: readonly { path: string }[] + disabled: boolean + isDispatchingSessionOption: boolean + launchDraft?: NativeChatLaunchDraft | null + launchDraftResolved: boolean + readTerminalScreen?: () => string | null + resolveTarget: () => NativeChatResolvedTarget | null + classifySend: NativeChatPickerState['classifySend'] + onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + onSlashCommand?: (command: string) => void + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + terminalTabId: string + trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + clearSkillOrigin: () => void + clearImageAttachments: () => void + setNotice: Dispatch> +}): () => void { + return useCallback(() => { + const text = args.draft + const imagePaths = args.imageAttachments.map((attachment) => attachment.path) + if ((text.trim() === '' && imagePaths.length === 0) || args.disabled) { + return + } + // Why: keep option-command and prompt writes from interleaving on the PTY input line. + if (args.isDispatchingSessionOption) { + return + } + const target = args.resolveTarget() + if (!target) { + return + } + const classification = args.classifySend(text) + const { sendOptions } = resolveNativeChatLaunchDraftSend({ + launchDraft: args.launchDraft, + launchDraftResolved: args.launchDraftResolved, + agent: args.agent, + readScreen: () => args.readTerminalScreen?.() + }) + let pendingHandle: NativeChatSendHandle | null = null + // Why: slash-like text must not silently drop its attached images. + if (classification !== 'chat' && imagePaths.length === 0) { + pendingHandle = + args.agent === 'codex' && isSlashCommandDraft(text) + ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) + : sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + } else if (imagePaths.length > 0) { + pendingHandle = sendNativeChatMessageWithImageAttachments( + target.settings, + target.ptyId, + text, + imagePaths, + sendOptions + ) + } else if (text.trim().length > 0) { + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + } else { + submitNativeChatPrompt(target.settings, target.ptyId) + } + if (classification !== 'chat') { + if (pendingHandle) { + args.trackPendingSend(pendingHandle) + } + if (classification === 'command') { + args.onSlashCommand?.(text.trim()) + args.sessionOptionsSurface?.recordOutgoingCommand(text.trim()) + } + } else { + const pendingId = args.onOptimisticSend?.(text, imagePaths) + if (pendingHandle) { + args.trackPendingSend(pendingHandle, pendingId) + } + } + emitNativeChatMessageSent({ + agent: args.agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + args.setHistory((previous) => pushHistory(previous, text)) + args.setDraft('') + args.setCaret(0) + args.clearSkillOrigin() + args.clearImageAttachments() + args.setNotice(null) + useAppStore.getState().clearNativeChatLaunchDraft(args.terminalTabId) + }, [args]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index a436ba3c7ad..f360394cf37 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -44,6 +44,7 @@ function stateForHost(hostId: string) { restoredRuntimeHostIdByWorkspaceSessionKey: {}, settings: { activeRuntimeEnvironmentId: null }, tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, worktreesByRepo: { 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }] } @@ -111,6 +112,46 @@ describe('useNativeChatSkills', () => { ) }) + it('resolves the catalog for a structured session tab', async () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'worktree-1': [{ id: 'tab-1', contentType: 'agent-session', entityId: 'session-1' }] + } + } + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) + + it('surfaces discovery failure instead of remaining loading', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed')) + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('error')) + expect(mocks.snapshots.at(-1)?.error?.message).toBe('scan failed') + }) + + it('surfaces missing tab ownership instead of remaining loading', () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + } + render() + + expect(mocks.snapshots.at(-1)?.status).toBe('error') + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + it('shares one in-flight request between sibling panes', async () => { render( <> diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 6e5daaf43db..9baf747673a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -132,6 +132,23 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { ).toBe('/repo/worktree') }) + it('returns the owning worktree path for a structured session tab', () => { + expect( + resolveNativeChatSkillDiscoveryCwd( + { + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'repo-1::/repo/worktree': [{ id: 'structured-tab-1' }] + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/worktree', path: '/repo/worktree' }] + } + }, + 'structured-tab-1' + ) + ).toBe('/repo/worktree') + }) + it('returns null when the tab has no known worktree owner', () => { expect( resolveNativeChatSkillDiscoveryCwd({ tabsByWorktree: {}, worktreesByRepo: {} }, 'tab-1') diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index f8a7e943935..42adc911656 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -43,6 +43,13 @@ const IDLE_STATE: StoredDiscoveryState = { error: null, contextKey: null } +const MISSING_CONTEXT_STATE: StoredDiscoveryState = { + status: 'error', + skills: [], + error: new Error('Skill discovery context is unavailable.'), + errorKind: 'unknown', + contextKey: null +} const inFlightDiscovery = new Map>() export function isNativeChatSkillForAgent( @@ -175,11 +182,13 @@ export function useNativeChatSkills( const effectiveState = useMemo( () => - !profile || !enabled || !context + !profile || !enabled ? IDLE_STATE - : state.contextKey === context.key - ? state - : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, + : !context + ? MISSING_CONTEXT_STATE + : state.contextKey === context.key + ? state + : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, [context, enabled, profile, state] ) const visibleSkills = useMemo(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts b/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts new file mode 100644 index 00000000000..6f3778dcc8c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts @@ -0,0 +1,20 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../../store' +import { findTabAgentEntry } from './native-chat-tab-agent-entry' + +export function useNativeChatStatusEntry( + terminalTabId: string, + preferredPaneKey: string | undefined +) { + const entry = useAppStore( + useShallow((state) => + preferredPaneKey + ? state.agentStatusByPaneKey[preferredPaneKey] + : findTabAgentEntry(state.agentStatusByPaneKey, terminalTabId) + ) + ) + return { + entry, + paneKey: preferredPaneKey ?? entry?.paneKey ?? `${terminalTabId}:` + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx new file mode 100644 index 00000000000..d8c9981ad04 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +// A structured chat is a view on a terminal tab, so closing the tab is an unmount and nothing else. +// If that unmount does not reach main, the codex app-server behind the chat has no other way to +// learn the chat is gone. + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: vi.fn() +})) + +import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' + +const LOCAL_TARGET = { kind: 'local' } as const + +function callsTo(method: string): unknown[] { + return mocks.call.mock.calls.filter((call) => call[1] === method).map((call) => call[2]) +} + +beforeEach(() => { + mocks.call.mockReset() + mocks.call.mockResolvedValue(undefined) +}) + +describe('a mounted structured chat', () => { + it('holds the session while it is on screen and releases it on unmount', async () => { + const { unmount } = renderHook(() => + useStructuredAgentSessionHold({ + sessionId: 'session-alpha', + target: LOCAL_TARGET, + surface: 'desktop-chat' + }) + ) + + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + const held = callsTo('agentSession.hold')[0] as { sessionId: string; holderId: string } + expect(held.sessionId).toBe('session-alpha') + expect(callsTo('agentSession.release')).toHaveLength(0) + + unmount() + + await waitFor(() => + expect(callsTo('agentSession.release')).toEqual([ + { sessionId: 'session-alpha', holderId: held.holderId } + ]) + ) + }) + + it('does not release a hold that has not landed yet', async () => { + let settleHold = (): void => {} + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.hold' + ? new Promise((resolve) => { + settleHold = resolve + }) + : Promise.resolve() + ) + const { unmount } = renderHook(() => + useStructuredAgentSessionHold({ + sessionId: 'session-alpha', + target: LOCAL_TARGET, + surface: 'desktop-chat' + }) + ) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + unmount() + // The hold is still in flight; releasing now would leave the late hold with nothing to undo it. + expect(callsTo('agentSession.release')).toHaveLength(0) + + settleHold() + + await waitFor(() => expect(callsTo('agentSession.release')).toHaveLength(1)) + }) + + it('keeps one hold across re-renders that rebuild the target object', async () => { + const { rerender, unmount } = renderHook( + (props: { sessionId: string }) => + useStructuredAgentSessionHold({ + sessionId: props.sessionId, + target: { kind: 'local' }, + surface: 'desktop-chat' + }), + { initialProps: { sessionId: 'session-alpha' } } + ) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + rerender({ sessionId: 'session-alpha' }) + rerender({ sessionId: 'session-alpha' }) + + expect(callsTo('agentSession.hold')).toHaveLength(1) + expect(callsTo('agentSession.release')).toHaveLength(0) + unmount() + }) + + it('holds only while a retained pane is visible', async () => { + const view = renderHook( + ({ visible }: { visible: boolean }) => + useStructuredAgentSessionHold({ + sessionId: 'session-restored', + target: LOCAL_TARGET, + surface: 'desktop-chat', + enabled: visible + }), + { initialProps: { visible: false } } + ) + + expect(callsTo('agentSession.hold')).toHaveLength(0) + view.rerender({ visible: true }) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + view.rerender({ visible: false }) + await waitFor(() => expect(callsTo('agentSession.release')).toHaveLength(1)) + + view.rerender({ visible: true }) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(2)) + expect(callsTo('agentSession.release')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts new file mode 100644 index 00000000000..b7288d4a2a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -0,0 +1,59 @@ +// The desktop chat telling main that this session is on screen. +// +// A structured chat is an in-place view on a terminal tab, so closing the tab unmounts this and +// nothing else in the close path knows a provider process is involved: `closeUnifiedTab` retires +// the PTY and drops the tab, main hears nothing, and a codex app-server outlives the chat for the +// rest of the app's life. Surface activity is the honest signal — it covers closing the tab, +// closing the window, and visibility changes for retained panes, none of which share a code path. +// +// The release CHAINS off the hold rather than racing it: an unmount during the hold's round trip +// would otherwise release a hold that has not landed yet, and the late hold would never be undone. + +import { useEffect, useRef } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +let holderOrdinal = 0 + +export function structuredAgentSessionHolderId(surface: string): string { + holderOrdinal += 1 + return `${surface}:${holderOrdinal}` +} + +export function useStructuredAgentSessionHold(args: { + sessionId: string + target: RuntimeClientTarget + surface: string + enabled?: boolean +}): void { + const { enabled = true, sessionId, surface, target } = args + // Keyed by VALUE, not identity: callers build the target inline, so an identity dependency would + // release and re-take the hold on every render of the pane. + const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` + const targetRef = useRef(target) + // Synced in an effect declared first (so it lands before the hold below) rather than in render: + // a render React discards must not leak its target into the next commit. + useEffect(() => { + targetRef.current = target + }, [target]) + useEffect(() => { + if (!enabled) { + return + } + const runtimeTarget = targetRef.current + const holderId = structuredAgentSessionHolderId(surface) + const held = callStructuredAgentSession(runtimeTarget, 'agentSession.hold', { + sessionId, + holderId + // An older host has no such method; the session still reads, it just is not held. + }).catch(() => undefined) + return () => { + void held.then(() => + callStructuredAgentSession(runtimeTarget, 'agentSession.release', { + sessionId, + holderId + }).catch(() => undefined) + ) + } + }, [enabled, sessionId, surface, targetKey]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx new file mode 100644 index 00000000000..403769144fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -0,0 +1,454 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ + call: vi.fn() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +const LOCAL_TARGET = { kind: 'local' } as const + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function acceptedResult(fence: number) { + return { + ok: true, + replayed: false, + fence, + cursor: { epoch: 'epoch-1', sequence: fence }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: fence, + resolvedAt: fence + } + } + } +} + +function acceptedResultFor(clientMessageId: string, fence: number) { + return { + ok: true, + replayed: false, + fence, + cursor: { epoch: 'epoch-1', sequence: fence }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: fence, + resolvedAt: fence + } + } + } +} + +function unknownResultFor(clientMessageId: string, submittedAt: number) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: submittedAt }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown' as const, + providerItemId: null, + reason: 'socket closed', + submittedAt, + resolvedAt: submittedAt + } + } + } +} + +function refusedResult(code: AgentSessionWireRefusalCode) { + return { ok: false, refusal: { code, message: code } } +} + +describe('useStructuredAgentSessionOutbox', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111' + ) + }) + + it('requeues across a fence change and ignores the stale settlement', async () => { + const first = deferred>() + const second = deferred>() + mocks.call.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { result, rerender } = renderHook( + ({ fence }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence, + submissions: [] + }), + { initialProps: { fence: 1 } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + + rerender({ fence: 2 }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ + envelope: { expectedRuntimeFence: 2 } + }) + + await act(async () => first.resolve(acceptedResult(1))) + expect(result.current.outbox).toHaveLength(1) + + await act(async () => second.resolve(acceptedResult(2))) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + + it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)( + 'rotates a send operation after %s', + async (code) => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockReturnValueOnce('22222222-2222-4222-8222-222222222222') + mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued')) + const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } }) + .envelope.clientOperationId + const retryId = result.current.outbox[0]!.clientMessageId + expect(retryId).not.toBe(firstId) + + act(() => result.current.retry(retryId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect( + (mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + ).toBe(retryId) + } + ) + + it('retains a send operation after a pending-admission refusal', async () => { + mocks.call + .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) + .mockResolvedValueOnce(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued')) + const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } }) + .envelope.clientOperationId + expect(result.current.outbox[0]?.clientMessageId).toBe(firstId) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect( + (mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + ).toBe(firstId) + }) + + it('persists and dispatches an attachment-only structured send', async () => { + mocks.call.mockResolvedValue(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => + expect( + result.current.send('', [{ path: '/tmp/image.png', previewUri: 'file:///tmp/image.png' }]) + ).toBe(true) + ) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: '/tmp/image.png' }] + } + }) + }) + + it('retries an unknown head and advances a queued tail', async () => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + mocks.call + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 11) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 12) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) + + act(() => { + expect(result.current.send('first')).toBe(true) + }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + const firstId = result.current.outbox[0]!.clientMessageId + rerender({ + submissions: [ + { + clientMessageId: firstId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'socket closed', + submittedAt: 10, + resolvedAt: 10 + } + ] + }) + act(() => { + expect(result.current.send('second')).toBe(true) + }) + expect(result.current.outbox).toHaveLength(2) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + const retryParams = mocks.call.mock.calls[1]?.[2] as { retryUnknown?: true } | undefined + expect(retryParams?.retryUnknown).toBe(true) + }) + + it('rotates a history-rejected unknown head so the queued tail can advance', async () => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + .mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc') + mocks.call + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 11) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 12) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + const firstId = result.current.outbox[0]!.clientMessageId + act(() => expect(result.current.send('second')).toBe(true)) + rerender({ + submissions: [ + { + clientMessageId: firstId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'not_delivered', + submittedAt: 10, + resolvedAt: 10 + } + ] + }) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + const retryParams = mocks.call.mock.calls[1]?.[2] as + | { envelope: { clientOperationId: string } } + | undefined + expect(retryParams?.envelope.clientOperationId).not.toBe(firstId) + }) + + it('loads the new session outbox when a pane switches sessions', async () => { + mocks.call.mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }), + { initialProps: { sessionId: 'session-1' } } + ) + + act(() => expect(result.current.send('first session')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + + rerender({ sessionId: 'session-2' }) + expect(result.current.outbox).toHaveLength(0) + + act(() => expect(result.current.send('second session')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ + envelope: { sessionId: 'session-2' }, + body: { + blocks: [{ type: 'text', text: 'second session' }] + } + }) + }) + + it('drops a session error on switch and does not resurrect it on return', async () => { + const redispatch = deferred>() + mocks.call + .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) + .mockReturnValueOnce(redispatch.promise) + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }), + { initialProps: { sessionId: 'session-1' } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.error).toBe('agent_session_checkpoint_stale')) + + rerender({ sessionId: 'session-2' }) + expect(result.current.error).toBeNull() + + rerender({ sessionId: 'session-1' }) + expect(result.current.error).toBeNull() + }) + + it('invalidates an old dispatch before it settles during a session switch', async () => { + const oldDispatch = deferred>() + const sessionTwoCommitted = deferred() + mocks.call.mockReturnValueOnce(oldDispatch.promise) + const controllerRef: { + current: ReturnType | null + } = { current: null } + function Probe({ sessionId }: { sessionId: string }): null { + controllerRef.current = useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + useLayoutEffect(() => { + if (sessionId === 'session-2') { + oldDispatch.resolve(refusedResult('agent_session_checkpoint_stale')) + sessionTwoCommitted.resolve() + } + }, [sessionId]) + return null + } + + const container = document.createElement('div') + const root = createRoot(container) + const actEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT + try { + await act(async () => root.render()) + act(() => expect(controllerRef.current?.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + const oldSettlementProcessed = oldDispatch.promise.then(() => undefined) + + globalThis.IS_REACT_ACT_ENVIRONMENT = false + root.render() + await sessionTwoCommitted.promise + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => oldSettlementProcessed) + + expect(controllerRef.current?.error).toBeNull() + await act(async () => root.render()) + expect(controllerRef.current?.error).toBeNull() + } finally { + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => root.unmount()) + } + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts new file mode 100644 index 00000000000..56ccefe3f76 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -0,0 +1,317 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionMutationResult, + AgentSessionSendResult +} from '../../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' +import { + classifyStructuredAgentSessionSendFailure, + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionSendRequest, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' + +export function structuredSessionOperationId(): string { + return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) +} + +function storageKey(sessionId: string): string { + return `${OUTBOX_PREFIX}${encodeURIComponent(sessionId)}` +} + +function readOutbox(sessionId: string): StructuredAgentSessionOutboxEntry[] { + try { + const value = JSON.parse(localStorage.getItem(storageKey(sessionId)) ?? '[]') + return Array.isArray(value) + ? value + .map((entry) => parseStructuredAgentSessionOutboxEntry(entry, sessionId)) + .filter((entry): entry is StructuredAgentSessionOutboxEntry => entry !== null) + .map((entry) => + entry.state === 'dispatching' ? { ...entry, state: 'unconfirmed' as const } : entry + ) + .sort((left, right) => left.queuedAt - right.queuedAt) + : [] + } catch { + return [] + } +} + +function writeOutbox( + sessionId: string, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + try { + if (entries.length === 0) { + localStorage.removeItem(storageKey(sessionId)) + } else { + localStorage.setItem(storageKey(sessionId), JSON.stringify(entries)) + } + return true + } catch { + return false + } +} + +function isDesktopDeliveryUnknown(error: unknown): boolean { + const text = error instanceof Error ? `${error.name}:${error.message}` : String(error) + return /timeout|disconnect|connection|closed|unavailable|cutover/i.test(text) +} + +export function useStructuredAgentSessionOutbox(args: { + sessionId: string + target: RuntimeClientTarget + fence: number | null + submissions: readonly AgentJournalSubmission[] +}) { + const { fence, sessionId, submissions, target } = args + const [outbox, setOutbox] = useState(() => + readOutbox(sessionId) + ) + const outboxRef = useRef(outbox) + const outboxSessionRef = useRef(sessionId) + const dispatchingRef = useRef(false) + const dispatchGenerationRef = useRef(0) + const blockedIdRef = useRef(null) + const [error, setError] = useState(null) + const [errorSession, setErrorSession] = useState(sessionId) + // Render-time reset (react.dev: adjusting state when a prop changes), so the + // old session's banner neither flashes for a frame nor resurrects on return. + if (errorSession !== sessionId) { + setErrorSession(sessionId) + setError(null) + } + + useEffect(() => { + outboxRef.current = outbox + }, [outbox]) + + useLayoutEffect(() => { + dispatchGenerationRef.current += 1 + dispatchingRef.current = false + blockedIdRef.current = null + }, [fence, sessionId, target]) + + useEffect(() => { + const sessionChanged = outboxSessionRef.current !== sessionId + outboxSessionRef.current = sessionId + const current = sessionChanged ? readOutbox(sessionId) : outboxRef.current + const next = current.map((entry) => + entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry + ) + if ( + sessionChanged || + next.some((entry, index) => entry !== current[index]) || + next.length !== current.length + ) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, [fence, sessionId, target]) + + useEffect(() => { + const next = reconcileStructuredAgentSessionOutbox(outboxRef.current, submissions) + if ( + next.some((entry, index) => entry !== outboxRef.current[index]) || + next.length !== outboxRef.current.length + ) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, [sessionId, submissions]) + + useEffect(() => { + const next = outbox[0] + if ( + !next || + next.sessionId !== sessionId || + next.state !== 'queued' || + fence === null || + dispatchingRef.current || + blockedIdRef.current === next.clientMessageId + ) { + return + } + dispatchingRef.current = true + const dispatchGeneration = dispatchGenerationRef.current + const staged = [ + { ...next, state: 'dispatching' as const, lastAttemptAt: Date.now() }, + ...outbox.slice(1) + ] + if (!writeOutbox(sessionId, staged)) { + dispatchingRef.current = false + blockedIdRef.current = next.clientMessageId + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = staged + setOutbox(staged) + void callStructuredAgentSession>( + target, + 'agentSession.send', + structuredAgentSessionSendRequest(next, fence) + ) + .then((result) => { + if (dispatchGenerationRef.current !== dispatchGeneration) { + return + } + if (!result.ok) { + setError(result.refusal.message) + const updated = outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? requeueStructuredAgentSessionSendRefusal( + entry, + result.refusal.code, + structuredSessionOperationId + ) + : entry + ) + blockedIdRef.current = updated[0]?.clientMessageId ?? null + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + return + } + const submission = result.value.submission + if (submission.dispatchState === 'rejected') { + blockedIdRef.current = next.clientMessageId + setError(submission.reason ?? 'Message was not accepted') + } else { + setError(null) + } + const updated = + submission.dispatchState === 'accepted' + ? outboxRef.current.filter((entry) => entry.clientMessageId !== next.clientMessageId) + : outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? { + ...entry, + state: + submission.dispatchState === 'unknown' + ? ('unconfirmed' as const) + : ('queued' as const) + } + : entry + ) + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + }) + .catch((caught) => { + if (dispatchGenerationRef.current !== dispatchGeneration) { + return + } + const failure = classifyStructuredAgentSessionSendFailure(caught, isDesktopDeliveryUnknown) + if (failure === 'failed') { + blockedIdRef.current = next.clientMessageId + } + const updated = outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? { + ...entry, + state: + failure === 'delivery-unknown' ? ('unconfirmed' as const) : ('queued' as const) + } + : entry + ) + setError( + failure === 'delivery-unknown' ? 'Message delivery is unconfirmed' : String(caught) + ) + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + }) + .finally(() => { + if (dispatchGenerationRef.current === dispatchGeneration) { + dispatchingRef.current = false + } + }) + }, [fence, outbox, sessionId, target]) + + const send = useCallback( + (text: string, attachments: readonly { path: string; previewUri: string }[] = []): boolean => { + if (!text.trim() && attachments.length === 0) { + return false + } + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: structuredSessionOperationId(), + sessionId, + text, + attachments, + queuedAt: Date.now() + }) + const next = [...outboxRef.current, entry] + if (!writeOutbox(sessionId, next)) { + setError('Message could not be saved to the outbox') + return false + } + outboxRef.current = next + setOutbox(next) + setError(null) + return true + }, + [sessionId] + ) + + const retry = (clientMessageId: string): void => { + blockedIdRef.current = null + setError(null) + const submission = submissions.find( + (candidate) => candidate.clientMessageId === clientMessageId + ) + const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) + // A provider-history reconciliation can settle an earlier unknown as + // rejected before the user presses Retry. Reusing that operation id only + // replays the settled rejection forever, so rotate the id for a safe resend. + if (current && submission?.dispatchState === 'rejected') { + const rotated = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...entry, + clientMessageId: structuredSessionOperationId(), + state: 'queued' as const, + retryAfterUnknownSubmittedAt: null + } + : entry + ) + if (!writeOutbox(sessionId, rotated)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = rotated + setOutbox(rotated) + return + } + const retryAfterUnknownSubmittedAt = + submission?.dispatchState === 'unknown' + ? submission.submittedAt + : current?.state === 'unconfirmed' + ? -1 + : null + const next = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...entry, + state: 'queued' as const, + retryAfterUnknownSubmittedAt + } + : entry + ) + if (!writeOutbox(sessionId, next)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = next + setOutbox(next) + } + return { outbox, error, blockedClientMessageId: blockedIdRef.current, send, retry } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx new file mode 100644 index 00000000000..2d320694c9c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -0,0 +1,430 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalCursor, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryPage, + type AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { + useStructuredAgentSessionRead, + useStructuredAgentSessionReadObservation +} from './use-structured-agent-session-read' +import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' + +const LOCAL_TARGET = { kind: 'local' } as const + +function message(id: string, sequence: number, role: 'user' | 'assistant'): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role, blocks: [{ type: 'text', text: id }] } + } +} + +function providerFrame(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + text: id, + providerFrame: { + provider: 'codex', + kind: 'notification:item/commandExecution/outputDelta', + payload: { head: id, byteLength: id.length, digest: id, truncated: false } + } + } + } +} + +function page( + direction: 'tail' | 'before', + items: AgentJournalRenderItem[], + hasOlder: boolean, + epoch = 'epoch-a' +): AgentSessionHistoryPage { + const cursor = (sequence: number): AgentJournalCursor => ({ epoch, sequence }) + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? oldest + return { + sessionId: 'session-a', + epoch, + direction, + items, + removedItemIds: [], + submissions: [], + window: { + oldest: items.length > 0 ? cursor(oldest) : null, + newest: items.length > 0 ? cursor(newest) : null, + nextCursor: cursor(oldest) + }, + liveCursor: cursor(500), + hasOlder, + hasNewer: direction === 'before' + } +} + +describe('useStructuredAgentSessionRead history window', () => { + afterEach(cleanup) + + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + }) + + it('restores a realistic 21-turn window across the wire-safe bridge-sized read', async () => { + const items = Array.from({ length: 21 }, (_, turn) => [ + message(`user-${turn}`, turn * 2 + 1, 'user'), + message(`assistant-${turn}`, turn * 2 + 2, 'assistant') + ]).flat() + const olderItems = items.slice(0, 12) + const tailItems = [ + ...Array.from({ length: 170 }, (_, index) => providerFrame(`delta-${index}`, 43 + index)), + ...items.slice(12).map((item, index) => ({ ...item, sequence: 213 + index })) + ] + mocks.call + .mockResolvedValueOnce({ ok: true, page: page('tail', tailItems, true) }) + .mockResolvedValueOnce({ ok: true, page: page('before', olderItems, false) }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + await waitFor(() => + expect( + result.current.state.items.filter((item) => item.body.kind === 'message') + ).toHaveLength(items.length) + ) + expect(mocks.call).toHaveBeenNthCalledWith(1, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'tail', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(mocks.call).toHaveBeenNthCalledWith(2, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: tailItems[0].sequence }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + }) + + it('loads each earlier page at the wire maximum', async () => { + const tailItems = Array.from({ length: 200 }, (_, index) => + message(`tail-${index}`, 301 + index, 'assistant') + ) + const initialOlderItems = Array.from({ length: 100 }, (_, index) => + message(`middle-${index}`, 201 + index, 'assistant') + ) + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', tailItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', initialOlderItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', [message('oldest', 1, 'user')], false) + }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + await waitFor(() => expect(result.current.state.hasOlder).toBe(true)) + + await act(async () => result.current.loadOlder()) + + expect(mocks.call).toHaveBeenLastCalledWith(LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: 201 }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(result.current.state.items).toHaveLength(301) + expect(result.current.state.items[0]?.itemId).toBe('oldest') + }) + + it('refreshes only visible structured sessions when the app regains focus', async () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) + const visible = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-visible', + target: LOCAL_TARGET, + isVisible: true + }) + ) + const hidden = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-hidden', + target: LOCAL_TARGET, + isVisible: false + }) + ) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + expect(mocks.subscribe).toHaveBeenCalledTimes(1) + + act(() => window.dispatchEvent(new Event('focus'))) + + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call).toHaveBeenLastCalledWith(LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-visible', + direction: 'tail', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + visible.unmount() + hidden.unmount() + hasFocus.mockRestore() + }) + + it('drops a delayed refresh after reconnect without mutating state or provider session', async () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const delayedRefresh = Promise.withResolvers<{ + ok: true + page: AgentSessionHistoryPage + providerSession: { key: 'session_id'; id: string } + }>() + const closes: (() => void)[] = [] + const initialProviderSession = { key: 'session_id', id: 'provider-initial' } as const + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', [message('initial', 1, 'assistant')], false), + providerSession: initialProviderSession + }) + .mockReturnValueOnce(delayedRefresh.promise) + mocks.subscribe.mockImplementation((_target, _params, _onEvent, _onError, onClose) => { + closes.push(onClose) + return Promise.resolve({ unsubscribe: vi.fn() }) + }) + + const view = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + try { + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) + expect(view.result.current.state.items[0]?.itemId).toBe('initial') + expect(view.result.current.providerSession).toBe(initialProviderSession) + const stateBeforeRefresh = view.result.current.state + + act(() => window.dispatchEvent(new Event('focus'))) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + + vi.useFakeTimers() + act(() => closes[0]?.()) + await act(async () => vi.advanceTimersByTimeAsync(750)) + expect(mocks.subscribe).toHaveBeenCalledTimes(2) + + await act(async () => { + delayedRefresh.resolve({ + ok: true, + page: page('tail', [message('stale', 2, 'assistant')], false), + providerSession: { key: 'session_id', id: 'provider-stale' } + }) + await delayedRefresh.promise + await Promise.resolve() + }) + + expect(view.result.current.state).toBe(stateBeforeRefresh) + expect(view.result.current.state.items[0]?.itemId).toBe('initial') + expect(view.result.current.providerSession).toBe(initialProviderSession) + } finally { + vi.useRealTimers() + view.unmount() + hasFocus.mockRestore() + } + }) + + it.each(['snapshot', 'reset'] as const)( + 'drops a delayed refresh after a same-stream %s advances the epoch', + async (eventType) => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const delayedRefresh = Promise.withResolvers<{ + ok: true + page: AgentSessionHistoryPage + providerSession: { key: 'session_id'; id: string } + }>() + const onEvents: ((event: AgentSessionSubscribeEvent) => void)[] = [] + const initialProviderSession = { key: 'session_id', id: 'provider-initial' } as const + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', [message('initial', 1, 'assistant')], false), + providerSession: initialProviderSession + }) + .mockReturnValueOnce(delayedRefresh.promise) + mocks.subscribe.mockImplementation((_target, _params, onEvent) => { + onEvents.push(onEvent) + return Promise.resolve({ unsubscribe: vi.fn() }) + }) + + const view = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + try { + await waitFor(() => expect(onEvents).toHaveLength(1)) + act(() => window.dispatchEvent(new Event('focus'))) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + + const replacementPage = page( + 'tail', + [message('new-epoch', 2, 'assistant')], + false, + 'epoch-b' + ) + const replacementEvent: AgentSessionSubscribeEvent = + eventType === 'reset' + ? { + type: 'reset', + sessionId: 'session-a', + reset: 'epoch_changed', + page: replacementPage, + fence: 2 + } + : { type: 'snapshot', sessionId: 'session-a', page: replacementPage, fence: 2 } + act(() => onEvents[0]?.(replacementEvent)) + + expect(view.result.current.state.epoch).toBe('epoch-b') + expect(view.result.current.state.items[0]?.itemId).toBe('new-epoch') + expect(view.result.current.providerSession).toBe(initialProviderSession) + const stateAfterReplacement = view.result.current.state + + await act(async () => { + delayedRefresh.resolve({ + ok: true, + page: page('tail', [message('stale-refresh', 3, 'assistant')], false), + providerSession: { key: 'session_id', id: 'provider-stale' } + }) + await delayedRefresh.promise + await Promise.resolve() + }) + + expect(view.result.current.state).toBe(stateAfterReplacement) + expect(view.result.current.state.epoch).toBe('epoch-b') + expect(view.result.current.state.items[0]?.itemId).toBe('new-epoch') + expect(view.result.current.providerSession).toBe(initialProviderSession) + } finally { + view.unmount() + hasFocus.mockRestore() + } + } + ) + + it('does no host work for retained inactive sessions', async () => { + const first = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-inactive-a', + target: LOCAL_TARGET, + isVisible: false + }) + ) + const second = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-inactive-b', + target: LOCAL_TARGET, + isVisible: false + }) + ) + + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + first.unmount() + second.unmount() + }) + + it('shares one subscriber when pane and projection observe the same visible session', async () => { + const unsubscribe = vi.fn() + mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) + mocks.subscribe.mockResolvedValue({ unsubscribe }) + + const view = renderHook(() => { + const pane = useStructuredAgentSessionRead({ + sessionId: 'session-shared', + target: LOCAL_TARGET, + isVisible: true + }) + const projection = useStructuredAgentSessionReadObservation({ + sessionId: 'session-shared', + target: LOCAL_TARGET + }) + return { pane, projection } + }) + + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) + expect(mocks.call).toHaveBeenCalledOnce() + expect(view.result.current.pane.state).toBe(view.result.current.projection.state) + + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('preserves cached state while switching away and refreshes once on re-entry', async () => { + const unsubscribe = vi.fn() + mocks.call.mockImplementation((_target, _method, params) => { + const sessionId = (params as { sessionId: string }).sessionId + return Promise.resolve({ + ok: true, + page: { + ...page('tail', [message(`${sessionId}-message`, 1, 'user')], false), + sessionId + } + }) + }) + mocks.subscribe.mockResolvedValue({ unsubscribe }) + const view = renderHook( + ({ active }: { active: 'first' | 'second' | null }) => ({ + first: useStructuredAgentSessionRead({ + sessionId: 'session-switch-a', + target: LOCAL_TARGET, + isVisible: active === 'first' + }), + second: useStructuredAgentSessionRead({ + sessionId: 'session-switch-b', + target: LOCAL_TARGET, + isVisible: active === 'second' + }) + }), + { initialProps: { active: null as 'first' | 'second' | null } } + ) + expect(mocks.call).not.toHaveBeenCalled() + + view.rerender({ active: 'first' }) + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1)) + expect(view.result.current.first.state.items[0]?.itemId).toBe('session-switch-a-message') + + view.rerender({ active: 'second' }) + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2)) + expect(unsubscribe).toHaveBeenCalledTimes(1) + + view.rerender({ active: 'first' }) + expect(view.result.current.first.state.items[0]?.itemId).toBe('session-switch-a-message') + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(3)) + expect(mocks.call).toHaveBeenCalledTimes(3) + expect(unsubscribe).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts new file mode 100644 index 00000000000..894730f5e65 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + getStructuredAgentSessionReadOwner, + type StructuredAgentSessionReadSnapshot +} from './structured-agent-session-read-owner' + +function useReadOwnerSnapshot( + sessionId: string, + target: RuntimeClientTarget +): { + owner: ReturnType + snapshot: StructuredAgentSessionReadSnapshot +} { + const owner = useMemo( + () => getStructuredAgentSessionReadOwner(sessionId, target), + [sessionId, target] + ) + const snapshot = useSyncExternalStore(owner.subscribe, owner.getSnapshot, owner.getSnapshot) + return { owner, snapshot } +} + +export function useStructuredAgentSessionReadObservation(args: { + sessionId: string + target: RuntimeClientTarget +}): StructuredAgentSessionReadSnapshot { + return useReadOwnerSnapshot(args.sessionId, args.target).snapshot +} + +export function useStructuredAgentSessionRead(args: { + sessionId: string + target: RuntimeClientTarget + isVisible?: boolean +}) { + const { sessionId, target, isVisible = true } = args + const { owner, snapshot } = useReadOwnerSnapshot(sessionId, target) + + useEffect(() => (isVisible ? owner.activate() : undefined), [isVisible, owner]) + + useEffect(() => { + if (!isVisible) { + return + } + const refresh = (): void => { + if (document.hasFocus()) { + owner.refresh() + } + } + window.addEventListener('focus', refresh) + return () => window.removeEventListener('focus', refresh) + }, [isVisible, owner]) + + return { + state: snapshot.state, + loadingOlder: snapshot.loadingOlder, + loadOlder: owner.loadOlder, + providerSession: snapshot.providerSession + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx new file mode 100644 index 00000000000..2e611e4c726 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), operationId: vi.fn() })) +let fence = 3 + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence, + items: [], + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +const LOCAL_TARGET = { kind: 'local' } as const + +const OPTIONS = { + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } + ] + } + ], + current: { model: 'gpt-live', effort: 'medium' } +} + +describe('useStructuredAgentSession options', () => { + beforeEach(() => { + vi.clearAllMocks() + fence = 3 + mocks.operationId + .mockReset() + .mockReturnValueOnce('operation-1') + .mockReturnValueOnce('operation-2') + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' ? Promise.resolve(OPTIONS) : Promise.resolve(null) + ) + }) + + it('applies provider-reconciled values after a model change', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: true, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')?.kind).toMatchObject( + { + currentValue: 'gpt-fast' + } + ) + expect( + result.current.optionSnapshot.find((entry) => entry.id === 'effort')?.kind + ).toMatchObject({ + currentValue: 'low' + }) + }) + + it('surfaces a rejected option transport call and clears pending state', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.reject(new Error('provider rejected option')) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + }) + + expect(result.current.error).toBe('provider rejected option') + expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ + settable: true + }) + }) + + it('mints a fresh operation when the same option is retried after a typed refusal', async () => { + let attempts = 0 + mocks.call.mockImplementation((_target, method) => { + if (method !== 'agentSession.setOption') { + // The hook also holds the session while it is mounted; only option writes are attempts. + return Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + } + attempts += 1 + return Promise.resolve( + attempts === 1 + ? { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'model list unavailable' + } + } + : { + ok: true, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + } + ) + }) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.setOption' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-2']) + }) + + it('reuses an option operation after a pending admission refusal', async () => { + let attempts = 0 + mocks.call.mockImplementation((_target, method) => { + if (method !== 'agentSession.setOption') { + // The hook also holds the session while it is mounted; only option writes are attempts. + return Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + } + attempts += 1 + return Promise.resolve( + attempts === 1 + ? { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'runtime fence advanced', + currentFence: 4 + } + } + : { + ok: true, + replayed: false, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + } + ) + }) + const { result, rerender } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + }) + fence = 4 + rerender() + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.setOption' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-1']) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { expectedRuntimeFence: number } }).envelope.expectedRuntimeFence + ) + ).toEqual([3, 4]) + expect(mocks.operationId).toHaveBeenCalledTimes(1) + }) + + it('ignores an option failure from a superseded fence', async () => { + let reject!: (error: Error) => void + const pending = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' ? Promise.resolve(OPTIONS) : pending + ) + const { result, rerender } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + let setting!: Promise + act(() => { + setting = result.current.setStructuredOption('model', 'gpt-fast') + }) + fence = 4 + rerender() + + await act(async () => { + reject(new Error('stale provider failure')) + await setting + }) + + expect(result.current.error).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts new file mode 100644 index 00000000000..5bea1af8c50 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionOptionsResult, + AgentSessionPromptResult +} from '../../../../shared/agent-session-wire' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../../shared/structured-agent-session-options' +import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + structuredSessionOperationId, + useStructuredAgentSessionOutbox +} from './use-structured-agent-session-outbox' +import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +export type StructuredPromptItem = AgentJournalRenderItem & { + body: Extract +} + +export function useStructuredAgentSession(args: { + sessionId: string + target: RuntimeClientTarget + agent: AgentType + isVisible: boolean +}) { + const { agent, isVisible, sessionId, target } = args + // Declared first: the hold is what gives a restored session its provider child back, and the + // read below is useless for sending until it lands. + useStructuredAgentSessionHold({ + sessionId, + target, + surface: 'desktop-chat', + enabled: isVisible + }) + const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({ + sessionId, + target, + isVisible + }) + const stateRef = useRef(state) + const [writeError, setWriteError] = useState(null) + const operationIds = useRef(new Map()) + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent) + ) + const activeOptionRecordRef = useRef(optionState.record) + const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const outboxController = useStructuredAgentSessionOutbox({ + sessionId, + target, + fence: state.fence, + submissions: state.submissions + }) + + useEffect(() => { + stateRef.current = state + }, [state]) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent) + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, sessionId, state.fence]) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null + ): Promise => { + if (stateRef.current.fence === null) { + return null + } + const targetFence = stateRef.current.fence + const key = `${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = + operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() + operationIds.current.set(key, clientOperationId) + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession>(target, method, { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: targetFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: fingerprintMethod, + sessionId, + fields + }) + }, + ...fields + }) + } catch (error) { + if (stateRef.current.fence === targetFence) { + setWriteError(error instanceof Error ? error.message : 'Request was not sent') + } + return null + } + if (!result.ok) { + if ( + agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) === + 'settled-rejected' + ) { + operationIds.current.delete(key) + } + if (stateRef.current.fence === targetFence) { + setWriteError(result.refusal.message) + } + return null + } + if (stateRef.current.fence !== targetFence) { + return null + } + operationIds.current.delete(key) + setWriteError(null) + return result.value + }, + [sessionId, target] + ) + + useEffect(() => { + if (!isVisible || !optionCatalog) { + return + } + let stale = false + void callStructuredAgentSession(target, 'agentSession.options', { + sessionId + }) + .then((result) => { + if (!stale) { + setOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => {}) + return () => { + stale = true + } + }, [isVisible, optionCatalog, sessionId, state.fence, target]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value } + ) + if (result && activeOptionRecordRef.current === targetRecord) { + setOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, result.options ?? { [id]: value }) + : current + ) + } + return Boolean(result) + } finally { + setOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + }, + [mutate, optionState] + ) + const setOption = useCallback( + async (id: string, value: string | boolean) => { + await setStructuredOption(id, value) + return { snapshot: optionSnapshot } + }, + [optionSnapshot, setStructuredOption] + ) + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + const prompts = state.items.filter( + (item): item is StructuredPromptItem => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + const turnId = activeStructuredAgentSessionTurnId(state.items) + return { + messages: projectStructuredAgentSessionMessages( + state.items, + outboxController.outbox, + state.submissions + ), + status: state.status, + error: state.error ?? writeError ?? outboxController.error, + hasOlder: state.hasOlder, + loadingOlder, + loadOlder, + prompts, + outbox: outboxController.outbox, + blockedClientMessageId: outboxController.blockedClientMessageId, + send: outboxController.send, + retry: outboxController.retry, + isWorking: turnId !== null, + turnId, + cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), + respond: (item: StructuredPromptItem, optionId: string) => + mutate( + item.body.kind === 'approval' + ? 'agentSession.respondToApproval' + : 'agentSession.respondToQuestion', + `agentSession.respondTo:${item.body.kind}`, + { itemId: item.itemId, expectedRevision: item.revision, optionId } + ), + optionSnapshot, + optionSurface, + setStructuredOption + } +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 8af06720ce0..389d60000f8 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -96,6 +96,7 @@ export function VaultSessionRow({ writeAiVaultSessionDragData(event.dataTransfer, { agent: session.agent, sessionId: session.sessionId, + ...(session.structuredSession ? { structuredSession: session.structuredSession } : {}), title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 4ede3cafe71..fbc14e6a19f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -2,8 +2,7 @@ import { useCallback, useState } from 'react' import { toast } from 'sonner' import { buildAiVaultResumeCopyCommandForWorktree, - buildAiVaultResumeStartupForWorktree, - type AiVaultResumeStartup + buildAiVaultResumeStartupForWorktree } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { @@ -29,6 +28,7 @@ import { import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' export function useAiVaultSessionLaunchActions({ activeWorktree, @@ -40,14 +40,7 @@ export function useAiVaultSessionLaunchActions({ activeWorktreeId: string | null targetState: AiVaultSessionResumeTargetState agentCmdOverrides?: Partial> -}): { - buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup - copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise - handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void - handleContinueInNewSession: (session: AiVaultSession, targetWorktreeId: string) => void - continuationRequest: AgentSessionContinuationRequest | null - handleContinuationDialogOpenChange: (open: boolean) => void -} { +}) { const [continuationRequest, setContinuationRequest] = useState(null) @@ -93,6 +86,10 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { + if (session.structuredSession) { + void activateAiVaultStructuredSession(session) + return + } const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 54264e1a4f6..b421b77bfc2 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -222,6 +222,94 @@ describe('ExperimentalPane', () => { expect(markup).toContain('aria-checked="true"') }) + it('shows the structured-native-chat child setting only when Chat UI is the default view', async () => { + const updateSettings = vi.fn() + const disabledSettings = getDefaultSettings('/tmp') + const disabledMarkup = renderToStaticMarkup( + + ) + expect(disabledMarkup).toContain('Chat UI') + expect(disabledMarkup).not.toContain('Use updated structured native chat') + expect(disabledMarkup).not.toContain('Default view') + + const terminalDefault = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: false + } + const terminalRender = await renderExperimentalPane({ + updateSettings, + settings: terminalDefault + }) + + // The default-view control is a sibling of the Chat UI toggle, never replaced by the opt-in. + expect(terminalRender.container.textContent).toContain('Default view') + expect( + terminalRender.container.querySelector('[data-slot="native-chat-default-view-select"]') + ).not.toBeNull() + // Structured chat has no entry path under Terminal chat, so its opt-in is not offered. + expect(terminalRender.container.textContent).not.toContain('Use updated structured native chat') + terminalRender.root.unmount() + + const { root, container } = await renderExperimentalPane({ + updateSettings, + settings: { ...terminalDefault, openAgentTabsInChatByDefault: true } + }) + + expect(container.textContent).toContain('Use updated structured native chat') + expect(container.textContent).toContain( + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + ) + expect(container.textContent).toContain('Default view') + root.unmount() + }) + + it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + expect(container.textContent).toContain('Use updated structured native chat') + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + // Switching the default view must not clobber the persisted opt-in — only hide its control. + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(1) + root.unmount() + + const hidden = await renderExperimentalPane({ + updateSettings, + settings: { ...settings, openAgentTabsInChatByDefault: false } + }) + + expect(hidden.container.textContent).not.toContain('Use updated structured native chat') + hidden.root.unmount() + + // Returning to Chat UI restores the control still switched on. + const restored = await renderExperimentalPane({ updateSettings, settings }) + const structuredSwitch = restored.container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + expect(structuredSwitch?.getAttribute('aria-checked')).toBe('true') + restored.root.unmount() + }) + it('shows Chat UI default-mode as a child setting only when Chat UI is enabled', async () => { const updateSettings = vi.fn() const disabledSettings = getDefaultSettings('/tmp') @@ -293,6 +381,46 @@ describe('ExperimentalPane', () => { secondRender.root.unmount() }) + // The two controls are nested, but each still writes only its own key. + it('never writes one Chat UI child setting while changing the other', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + const structuredSwitch = container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + if (!structuredSwitch) { + throw new Error('Structured native chat switch was not rendered') + } + + await act(async () => { + structuredSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalStructuredNativeChat: true }) + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(2) + root.unmount() + }) + it('renders the agent sleep idle duration as configurable minutes', async () => { const updateSettings = vi.fn() const settings = { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 6dac8022c8d..93c4b1c899d 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -19,8 +19,9 @@ export function NativeChatExperimentalSetting({ updateSettings }: NativeChatExperimentalSettingProps): React.JSX.Element { const nativeChatEnabled = settings.experimentalNativeChat === true - const openByDefault = settings.openAgentTabsInChatByDefault === true - const defaultView: NativeChatDefaultView = openByDefault ? 'native-chat' : 'terminal-chat' + const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true + const defaultView: NativeChatDefaultView = + settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' return ( {translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' + 'Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.' )}

@@ -60,7 +61,7 @@ export function NativeChatExperimentalSetting({ />
{nativeChatEnabled ? ( -
+
+ + {/* Structured chat rides the Chat UI default view; it has no entry path under Terminal + chat. Hidden only — the opt-in keeps its persisted value for when Chat UI returns. */} + {defaultView === 'native-chat' ? ( +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', + 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + )} +

+

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + )} +

+
+ + updateSettings({ + experimentalStructuredNativeChat: !structuredNativeChatEnabled + }) + } + /> +
+ ) : null}
) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 5af1a13a884..a940dd5f25b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -51,6 +51,7 @@ function mockAgent({ let mockAgents: DashboardAgentRowData[] = [] let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined let mockTabsByWorktree: Record = {} +let mockStructuredTabIds = new Set() let mockAgentStatusByPaneKey: Record = {} let mockActiveTabId: string | null = null let mockActiveTabType: string = 'editor' @@ -101,6 +102,10 @@ const staleAgentRowMocks = vi.hoisted(() => ({ dismissStaleAgentRowByKey: vi.fn() })) +const structuredActivationMocks = vi.hoisted(() => ({ + activateStructuredAgentSessionTab: vi.fn() +})) + vi.mock('@/store', () => ({ useAppStore: Object.assign( (selector: (state: unknown) => unknown) => selector(buildMockStoreState()), @@ -122,6 +127,10 @@ vi.mock('../terminal-pane/stale-agent-row', () => ({ dismissStaleAgentRowByKey: staleAgentRowMocks.dismissStaleAgentRowByKey })) +vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ + activateStructuredAgentSessionTab: structuredActivationMocks.activateStructuredAgentSessionTab +})) + vi.mock('./useWorktreeAgentRows', () => ({ useWorktreeAgentRows: vi.fn(() => mockAgents) })) @@ -155,10 +164,43 @@ describe('WorktreeCardAgents activation', () => { mockAgents = [] mockAgentActivityDisplayMode = undefined mockTabsByWorktree = {} + mockStructuredTabIds = new Set() mockAgentStatusByPaneKey = {} mockActiveTabId = null mockActiveTabType = 'editor' capturedRowActivations = [] + structuredActivationMocks.activateStructuredAgentSessionTab.mockImplementation( + ({ tabId }: { tabId: string }) => mockStructuredTabIds.has(tabId) + ) + }) + + it('activates a projected structured session row through the unified tab path', async () => { + mockAgentActivityDisplayMode = 'full' + const tabId = 'structured-tab' + const paneKey = makePaneKey(tabId, LEAF_A) + mockAgents = [ + mockAgent({ + paneKey, + tabId, + agentType: 'codex', + prompt: 'Structured session', + worktreeId: 'wt-1' + }) + ] + mockAgentStatusByPaneKey = { [paneKey]: { worktreeId: 'wt-1' } } + mockStructuredTabIds.add(tabId) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + renderToStaticMarkup() + capturedRowActivations[0].onActivate(tabId, paneKey) + + expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(structuredActivationMocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId + }) + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled() }) it('reveals the worktree and focuses an automation worker row hydrated during reveal', async () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 87ab769d138..3fe9a01398e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -26,6 +26,7 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' +import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -173,7 +174,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true }) - } else { + } else if (!activateStructuredAgentSessionTab({ worktreeId, tabId })) { const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey] if (liveEntry?.worktreeId === worktreeId) { // Why: orchestration worker status can be worktree-attributed before the renderer knows its tab; keep the live row instead of dismissing as stale. diff --git a/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts b/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts index 70f1f3ede4e..e94824aa1ba 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts @@ -4,6 +4,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' export type LiveEntriesByWorktreeCache = { tabsByWorktree: AppState['tabsByWorktree'] + unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] | undefined agentStatusByPaneKey: AppState['agentStatusByPaneKey'] entriesByWorktree: Map } diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts index 29b30fa34ba..cbd3da64212 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts @@ -3,6 +3,7 @@ import type { AgentStatusEntry, MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' +import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -152,6 +153,44 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([childEntry]) }) + it('keeps an idle structured session visible while its unified tab exists', () => { + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'done', + sessionBoundary: true, + worktreeId: 'wt-1', + tabId: 'tab-1' + }) + const structuredTab = { + id: 'tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + const state = { + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] }, + agentStatusByPaneKey: { [PANE_KEY_1]: entry }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {} + } + + expect(selectLiveAgentStatusEntriesForWorktree(state, 'wt-1')).toEqual([entry]) + expect( + selectLiveAgentStatusEntriesForWorktree( + { ...state, unifiedTabsByWorktree: { 'wt-1': [] } }, + 'wt-1' + ) + ).toEqual([]) + }) + it('patches instead of full-rebuilding across within-state pings, and stays correct on transitions', () => { const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'wt1 prompt' }) const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'wt2 prompt' }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index e9a6b5fde0b..4947fce433f 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -28,7 +28,9 @@ type WorktreeAgentRowsState = Pick< | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' | 'tabsByWorktree' -> +> & { + unifiedTabsByWorktree?: AppState['unifiedTabsByWorktree'] +} type TabWorktreeIndexCache = { tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] @@ -82,18 +84,38 @@ function getTabIdToWorktreeId( return tabIdToWorktreeId } +function getLiveTabIdToWorktreeId( + tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'], + unifiedTabsByWorktree: WorktreeAgentRowsState['unifiedTabsByWorktree'] +): Map { + const tabIdToWorktreeId = new Map(getTabIdToWorktreeId(tabsByWorktree)) + for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) { + for (const tab of tabs) { + if (tab.contentType === 'agent-session') { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + } + return tabIdToWorktreeId +} + function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map { const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD const tabsByWorktree = state.tabsByWorktree ?? EMPTY_RECORD + const unifiedTabsByWorktree = state.unifiedTabsByWorktree if ( liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree && + liveEntriesByWorktreeCache.unifiedTabsByWorktree === unifiedTabsByWorktree && liveEntriesByWorktreeCache.agentStatusByPaneKey === agentStatusByPaneKey ) { return liveEntriesByWorktreeCache.entriesByWorktree } - const tabIdToWorktreeId = getTabIdToWorktreeId(tabsByWorktree) - if (liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree) { + const tabIdToWorktreeId = getLiveTabIdToWorktreeId(tabsByWorktree, unifiedTabsByWorktree) + if ( + liveEntriesByWorktreeCache?.tabsByWorktree === tabsByWorktree && + liveEntriesByWorktreeCache.unifiedTabsByWorktree === unifiedTabsByWorktree + ) { const patched = patchLiveEntriesByWorktree( liveEntriesByWorktreeCache, agentStatusByPaneKey, @@ -102,6 +124,7 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map void } export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -82,10 +76,7 @@ export default function SortableTab({ onToggleExpand, dragData, dropIndicator, - includeTopTabBorder = true, - canToggleViewMode = false, - isChatView = false, - onToggleViewMode + includeTopTabBorder = true }: SortableTabProps): React.JSX.Element { // Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render. const hasUnreadActivity = useAppStore((s) => @@ -435,9 +426,6 @@ export default function SortableTab({ onRenameOpen={handleRenameOpen} onSetTabColor={onSetTabColor} onTogglePin={onTogglePin} - canToggleViewMode={canToggleViewMode} - isChatView={isChatView} - onToggleViewMode={onToggleViewMode} /> ) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index cd7c43096b1..76d5d8d145a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -207,6 +207,13 @@ describe('requestActiveTerminalPaneSplit', () => { }) describe('SortableTabContextMenu', () => { + it('does not expose a native/terminal view switch', () => { + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Switch to terminal view') + expect(container.textContent).not.toContain('Switch to chat view') + }) + it('dispatches split requests and activates inactive terminal tabs first', () => { const dispatchSpy = vi.spyOn(window, 'dispatchEvent') const { container, onActivate } = renderMenu({ isActive: false }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index c10118dc45d..53b31012d39 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,14 +1,4 @@ -import { - MessageSquare, - PanelLeftClose, - PanelRightClose, - Pin, - PinOff, - Pencil, - SquareTerminal, - X, - ListX -} from 'lucide-react' +import { PanelLeftClose, PanelRightClose, Pin, PinOff, Pencil, X, ListX } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -107,14 +97,6 @@ type SortableTabContextMenuProps = { onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void - /** True when this tab is an agent terminal that can switch to the native chat - * view; gates the "Switch view" menu item. */ - canToggleViewMode?: boolean - /** True when the tab is currently showing the native chat view (drives the - * item's label/icon between "chat" and "terminal"). */ - isChatView?: boolean - /** Toggle the tab between terminal and native chat view. */ - onToggleViewMode?: () => void } export function SortableTabContextMenu({ @@ -136,10 +118,7 @@ export function SortableTabContextMenu({ onCloseToLeft, onRenameOpen, onSetTabColor, - onTogglePin, - canToggleViewMode = false, - isChatView = false, - onToggleViewMode + onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) @@ -168,27 +147,6 @@ export function SortableTabContextMenu({ splitRightShortcut={splitRightShortcut} splitDownShortcut={splitDownShortcut} /> - {canToggleViewMode && onToggleViewMode ? ( - <> - - - {isChatView ? ( - - ) : ( - - )} - {isChatView - ? translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to chat view' - )} - - - ) : null} {isPinned ? ( diff --git a/src/renderer/src/components/tab-bar/group-tab-order.test.ts b/src/renderer/src/components/tab-bar/group-tab-order.test.ts index 6258c659700..ca693893893 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.test.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.test.ts @@ -64,7 +64,42 @@ function simulatorTab(id: string, groupId: string, sortOrder: number): Tab { } } +function agentSessionTab(id: string, groupId: string, sessionId: string, sortOrder: number): Tab { + return { + id, + entityId: sessionId, + groupId, + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + describe('getGroupVisibleTabOrder', () => { + it('includes structured sessions without a terminal backing entity', () => { + const group: TabGroup = { + id: 'g1', + worktreeId: 'wt', + activeTabId: 'tab-a1', + tabOrder: ['tab-t1', 'tab-a1'] + } + const tabs: Tab[] = [ + terminalTab('tab-t1', 'g1', 'term-1', 0), + agentSessionTab('tab-a1', 'g1', 'session-1', 1) + ] + expect(getGroupVisibleTabOrder(group, tabs, new Set(['term-1']), new Set(), new Set())).toEqual( + [ + { type: 'terminal', id: 'term-1', tabId: 'tab-t1' }, + { type: 'agent-session', id: 'session-1', tabId: 'tab-a1' } + ] + ) + }) + it('returns active-group refs with backing ids plus unified tab ids', () => { const group: TabGroup = { id: 'g1', @@ -398,9 +433,11 @@ describe('group order matches the rendered tab strip', () => { editorFileIds: [], browserTabIds: [], simulatorTabIds: [], + agentSessionTabIds: [], terminalMap: terminalMap as never, editorMap: new Map(), browserMap: new Map(), + agentSessionMap: new Map(), unifiedTabByVisibleId: new Map() }).map((item) => item.id) } diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index d24568f236a..c0755565ec2 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -3,7 +3,7 @@ import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' export type VisibleTabRef = { - type: 'terminal' | 'editor' | 'browser' | 'simulator' + type: 'terminal' | 'editor' | 'agent-session' | 'browser' | 'simulator' id: string tabId?: string } @@ -13,6 +13,7 @@ export type ActiveTabNavOrderIds = { editorIds?: string[] browserIds?: string[] simulatorIds?: string[] + agentSessionIds?: string[] } /** @@ -47,6 +48,10 @@ export function getGroupVisibleTabOrder( if (tab.contentType === 'simulator') { return simulatorTabIds.has(tab.id) ? { type: 'simulator', id: tab.id, tabId: tab.id } : null } + if (tab.contentType === 'agent-session') { + // Structured chat tabs are self-backed: the unified tab is the entity, so none can be stale. + return { type: 'agent-session', id: tab.entityId, tabId: tab.id } + } return editorEntityIds.has(tab.entityId) ? { type: 'editor', id: tab.entityId, tabId: tab.id } : null @@ -54,14 +59,19 @@ export function getGroupVisibleTabOrder( // Why: the strip keys terminals/browsers by entity id and editors/simulators by unified tab id // (see useTabGroupItemProjections) — reconcileTabOrder must see that same id domain. const visibleIdOf = (tab: Tab): string => - tab.contentType === 'terminal' || tab.contentType === 'browser' ? tab.entityId : tab.id + tab.contentType === 'terminal' || + tab.contentType === 'browser' || + tab.contentType === 'agent-session' + ? tab.entityId + : tab.id if (preserveTypeCollisions) { const seenByType = { terminal: new Set(), editor: new Set(), browser: new Set(), - simulator: new Set() + simulator: new Set(), + 'agent-session': new Set() } const result: VisibleTabRef[] = [] for (const unifiedId of group.tabOrder) { @@ -90,11 +100,13 @@ export function getGroupVisibleTabOrder( const editorIds: string[] = [] const browserIds: string[] = [] const simulatorIds: string[] = [] + const agentSessionIds: string[] = [] const idsByType = { terminal: terminalIds, editor: editorIds, browser: browserIds, - simulator: simulatorIds + simulator: simulatorIds, + 'agent-session': agentSessionIds } for (const tab of [...declaredTabs, ...groupTabs]) { const visibleId = visibleIdOf(tab) @@ -106,7 +118,13 @@ export function getGroupVisibleTabOrder( if (existing) { // Keep the same type precedence as buildOrderedTabItems, whose terminal map wins over // editor/browser/simulator maps when visible ids collide across content types. - const priority = { terminal: 0, editor: 1, browser: 2, simulator: 3 } as const + const priority = { + terminal: 0, + editor: 1, + browser: 2, + simulator: 3, + 'agent-session': 4 + } as const if (priority[ref.type] > priority[existing.type]) { continue } @@ -122,7 +140,8 @@ export function getGroupVisibleTabOrder( terminalIds, editorIds, browserIds, - simulatorIds + simulatorIds, + agentSessionIds ).flatMap((visibleId) => { const ref = refByVisibleId.get(visibleId) return ref ? [ref] : [] @@ -167,6 +186,11 @@ export function getActiveTabNavOrder( (state.unifiedTabsByWorktree[worktreeId] ?? []) .filter((tab) => tab.contentType === 'simulator') .map((tab) => tab.id) + const agentSessionIds = + ids.agentSessionIds ?? + (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'agent-session') + .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId @@ -199,12 +223,14 @@ export function getActiveTabNavOrder( terminalIds, editorIds, browserIds, - simulatorIds + simulatorIds, + agentSessionIds ) const terminalIdSet = new Set(terminalIds) const editorIdSet = new Set(editorIds) const browserIdSet = new Set(browserIds) const simulatorIdSet = new Set(simulatorIds) + const agentSessionIdSet = new Set(agentSessionIds) const result: VisibleTabRef[] = [] for (const id of visibleIds) { if (terminalIdSet.has(id)) { @@ -215,6 +241,13 @@ export function getActiveTabNavOrder( result.push({ type: 'browser', id }) } else if (simulatorIdSet.has(id)) { result.push({ type: 'simulator', id }) + } else if (agentSessionIdSet.has(id)) { + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id && candidate.contentType === 'agent-session' + ) + if (tab) { + result.push({ type: 'agent-session', id: tab.entityId, tabId: tab.id }) + } } } return result diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts index f613e2c0f4d..117585546f9 100644 --- a/src/renderer/src/components/tab-bar/reconcile-order.ts +++ b/src/renderer/src/components/tab-bar/reconcile-order.ts @@ -8,9 +8,16 @@ export function reconcileTabOrder( terminalIds: string[], editorIds: string[], browserIds: string[] = [], - simulatorIds: string[] = [] + simulatorIds: string[] = [], + agentSessionIds: string[] = [] ): string[] { - const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...simulatorIds]) + const validIds = new Set([ + ...terminalIds, + ...editorIds, + ...browserIds, + ...simulatorIds, + ...agentSessionIds + ]) // Why: storedOrder is persisted group tab order and is mutated by many // codepaths (drop/move/reorder/hydrate). A stale or racey write can leave // the same tab id twice in the list, which surfaces as React's "two @@ -25,7 +32,13 @@ export function reconcileTabOrder( inResult.add(id) } } - for (const id of [...terminalIds, ...editorIds, ...browserIds, ...simulatorIds]) { + for (const id of [ + ...terminalIds, + ...editorIds, + ...browserIds, + ...simulatorIds, + ...agentSessionIds + ]) { if (!inResult.has(id)) { result.push(id) inResult.add(id) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index 0eee66d7b68..d5b00ada161 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -40,6 +40,13 @@ export type TabBarItem = isPinned: boolean data: Tab } + | { + type: 'agent-session' + id: string + unifiedTabId: string + isPinned: boolean + data: Tab & { contentType: 'agent-session' } + } export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string { if (item.type === 'terminal') { @@ -48,7 +55,7 @@ export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolea if (item.type === 'browser') { return getBrowserTabLabel(item.data) } - if (item.type === 'simulator') { + if (item.type === 'simulator' || item.type === 'agent-session') { return item.data.label || 'Mobile Emulator' } return getEditorDisplayLabel(item.data) @@ -99,9 +106,11 @@ export function buildOrderedTabItems({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId }: { tabBarOrder?: string[] @@ -109,9 +118,11 @@ export function buildOrderedTabItems({ editorFileIds: string[] browserTabIds: string[] simulatorTabIds: string[] + agentSessionTabIds: string[] terminalMap: Map editorMap: Map browserMap: Map + agentSessionMap: Map unifiedTabByVisibleId: Map }): TabBarItem[] { const ids = reconcileTabOrder( @@ -119,7 +130,8 @@ export function buildOrderedTabItems({ terminalIds, editorFileIds, browserTabIds, - simulatorTabIds + simulatorTabIds, + agentSessionTabIds ) const items: TabBarItem[] = [] for (const id of ids) { @@ -168,6 +180,17 @@ export function buildOrderedTabItems({ isPinned: simulatorTab.isPinned === true, data: simulatorTab }) + continue + } + const agentSession = agentSessionMap.get(id) + if (agentSession) { + items.push({ + type: 'agent-session', + id, + unifiedTabId: agentSession.id, + isPinned: agentSession.isPinned === true, + data: agentSession + }) } } return items @@ -210,6 +233,9 @@ export function findActiveVisibleTabId( if (item.type === 'simulator') { return active.activeTabType === 'simulator' && item.id === active.activeSimulatorTabId } + if (item.type === 'agent-session') { + return active.activeTabType === 'agent-session' && item.id === active.activeTabId + } return ( (active.activeTabType === 'editor' || active.activeTabType === 'simulator') && active.activeFileId === item.id diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 0011531a7cd..c0af60cf6fc 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -1,8 +1,9 @@ import React from 'react' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { OpenFile } from '../../store/slices/editor' -import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' import BrowserTab from './BrowserTab' @@ -49,22 +50,13 @@ export function renderTabBarItems({ onActivateFile, onCloseFile, onActivateBrowserTab, + onActivateAgentSession, onCloseBrowserTab, onDuplicateBrowserTab, onCloseAllFiles, onMakePreviewFilePermanent } = props - const { - resolvedGroupId, - generatedTabTitlesEnabled, - unifiedTabByVisibleId, - nativeChatEnabled, - tabAgentTypesByTabId, - nativeChatTabWideFallbackUnsafeTabsById, - nativeChatTranscriptIsLocalReadable, - toggleTabViewMode, - statusByRelativePath - } = runtime + const { resolvedGroupId, generatedTabTitlesEnabled, statusByRelativePath } = runtime // A selected client-hosted row covers the pane, so the tab it covers must stop looking active — // the group's own activeTabId never moves for it, and two underlines would show at once. @@ -97,25 +89,6 @@ export function renderTabBarItems({ ...item.data, title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title) } - const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) - // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. - const resolvedAgent = - resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? - resolveCommittedTitleAgentType(terminalTab.title) - // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. - const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null - const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true - const canToggleViewMode = - unifiedTabForItem !== undefined && - canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, - contentType: 'terminal', - launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, - detectedAgent, - resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, - nativeChatTranscriptIsLocalReadable, - isChatViewMode: unifiedTabForItem.viewMode === 'chat' - }) return ( toggleTabViewMode(unifiedTabForItem.id) : undefined - } hasTabsToRight={index < items.length - 1} hasTabsToLeft={index > 0} isActive={ @@ -224,6 +192,51 @@ export function renderTabBarItems({ /> ) } + if (item.type === 'agent-session') { + const structuredTab: TerminalTab = { + id: item.id, + ptyId: null, + worktreeId, + title: item.data.label, + customTitle: item.data.customLabel, + color: item.data.color, + sortOrder: item.data.sortOrder, + createdAt: item.data.createdAt, + ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + : {}) + } + return ( + 0} + isActive={ + !clientHostedRowOwnsActiveState && + activeTabType === 'agent-session' && + item.id === activeTabId + } + isPinned={item.isPinned} + isExpanded={false} + onActivate={() => activateRealTab(onActivateAgentSession)(item.id)} + onClose={() => onClose(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} + onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} + onSetCustomTitle={onSetCustomTitle} + onSetTabColor={onSetTabColor} + onTogglePin={() => togglePinned(item)} + onToggleExpand={() => {}} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + } return ( void onCloseFile?: (fileId: string) => void onActivateBrowserTab?: (tabId: string) => void + onActivateAgentSession?: (tabId: string) => void onCloseBrowserTab?: (tabId: string) => void onDuplicateBrowserTab?: (tabId: string) => void onCloseAllFiles?: () => void diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index d12b275b556..c154a284b8a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -3,7 +3,10 @@ import { toast } from 'sonner' import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { + launchAgentInNewTab, + shouldQueueTerminalFocusAfterMenuClose +} from '@/lib/launch-agent-in-new-tab' import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' @@ -237,7 +240,9 @@ export function useTabBarCreateMenuController({ queueTerminalTabFocusAfterNewTabMenuClose(result.tabId) return } - queueNewActiveTerminalFocusAfterNewTabMenuClose() + if (shouldQueueTerminalFocusAfterMenuClose(result)) { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + } } const runPendingNewTabMenuFocusAfterClose = (): void => { const pendingFocus = pendingNewTabMenuFocusRef.current diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts index 3da80876b98..f48c3ab14c3 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -38,6 +38,7 @@ export function useTabBarItemProjection({ tabs, editorFiles, browserTabs, + agentSessionTabs, tabBarOrder, hoveredTabInsertion, activeTabId, @@ -56,6 +57,10 @@ export function useTabBarItemProjection({ () => new Map((browserTabs ?? []).map((tab) => [tab.id, tab])), [browserTabs] ) + const agentSessionMap = useMemo( + () => new Map((agentSessionTabs ?? []).map((tab) => [tab.id, tab])), + [agentSessionTabs] + ) const terminalIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]) const editorFileIds = useMemo( () => editorFiles?.map((file) => file.tabId ?? file.id) ?? [], @@ -69,6 +74,10 @@ export function useTabBarItemProjection({ .map((tab) => tab.id), [unifiedTabs, resolvedGroupId] ) + const agentSessionTabIds = useMemo( + () => agentSessionTabs?.map((tab) => tab.id) ?? [], + [agentSessionTabs] + ) const orderedItems = useMemo( () => buildOrderedTabItems({ @@ -77,9 +86,11 @@ export function useTabBarItemProjection({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId }), [ @@ -88,9 +99,11 @@ export function useTabBarItemProjection({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId ] ) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index a55aed7e886..55be0effcf7 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -23,6 +23,7 @@ import { resolveDropZone } from './tab-drop-zone' import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation' +import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' type PaneDropTarget = { groupId: string @@ -175,6 +176,18 @@ export default function AiVaultSessionDropLayer({ ) return true } + if (payload.structuredSession) { + const { sessionId, workspaceId } = payload.structuredSession + if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) { + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) + } + return true + } const state = useAppStore.getState() const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) @@ -224,6 +237,7 @@ export default function AiVaultSessionDropLayer({ }) ? window.api.aiVault.prepareSessionResume({ agent: payload.agent, + sessionId: payload.sessionId, filePath: payload.sessionFilePath, executionHostId: payload.sessionExecutionHostId, codexHome: payload.codexHome diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 06c31c79e83..6e48ddd0ec0 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -63,9 +63,16 @@ export default function TabGroupPanel({ }): React.JSX.Element { const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen) const sidebarOpen = useAppStore((state) => state.sidebarOpen) - const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) - const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model + const { + activeTab, + agentSessionItems, + browserItems, + commands, + editorItems, + tabBarOrder, + terminalTabs + } = model // Why: one strip owns the worktree's client-hosted rows, or every split repeats them. const ownsClientHostedRows = useAppStore( (state) => @@ -97,14 +104,20 @@ export default function TabGroupPanel({ const tabBar = ( { const item = resolveGroupTabFromVisibleId(model.groupTabs, terminalId) - if (item?.contentType === 'terminal') { + if (item?.contentType === 'terminal' || item?.contentType === 'agent-session') { commands.closeItem(item.id) return } @@ -143,8 +156,10 @@ export default function TabGroupPanel({ browserTabs={browserItems} clientHostedBrowserRows={clientHostedRows} groupActiveTabId={activeTab?.id ?? null} + agentSessionTabs={agentSessionItems} activeFileId={ activeTab?.contentType === 'terminal' || + activeTab?.contentType === 'agent-session' || activeTab?.contentType === 'browser' || activeTab?.contentType === 'simulator' ? null @@ -155,15 +170,18 @@ export default function TabGroupPanel({ activeTabType={ activeTab?.contentType === 'terminal' ? 'terminal' - : activeTab?.contentType === 'browser' - ? 'browser' - : activeTab?.contentType === 'simulator' - ? 'simulator' - : 'editor' + : activeTab?.contentType === 'agent-session' + ? 'agent-session' + : activeTab?.contentType === 'browser' + ? 'browser' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' } onActivateFile={commands.activateEditor} onCloseFile={commands.closeItem} onActivateBrowserTab={commands.activateBrowser} + onActivateAgentSession={commands.activateAgentSession} onCloseBrowserTab={(browserTabId) => { const item = model.groupTabs.find( (candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser' @@ -331,6 +349,7 @@ export default function TabGroupPanel({ ) : null} {activeTab && activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'agent-session' && activeTab.contentType !== 'browser' && activeTab.contentType !== 'simulator' && (
@@ -355,7 +374,7 @@ export default function TabGroupPanel({
)} - {/* Why: terminal/browser/simulator panes render at the worktree level (overlay layers); per-group rendering remounted xterm/webview/simulator on split moves. */} + {/* Why: terminal/browser/simulator/structured-chat panes render at the worktree level; tab activation only changes overlay visibility and never remounts a live surface. */}
) diff --git a/src/renderer/src/components/tab-group/tab-drag-data.ts b/src/renderer/src/components/tab-group/tab-drag-data.ts index 572385a0d14..eb582af01fc 100644 --- a/src/renderer/src/components/tab-group/tab-drag-data.ts +++ b/src/renderer/src/components/tab-group/tab-drag-data.ts @@ -10,7 +10,7 @@ export type TabDragItemData = { groupId: string unifiedTabId: string visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' | 'simulator' + tabType: 'terminal' | 'editor' | 'agent-session' | 'browser' | 'simulator' label: string iconPath?: string color?: string | null diff --git a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts index 0f28e730eab..7263472cb08 100644 --- a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts @@ -9,6 +9,7 @@ import { } from '../../runtime/web-runtime-session' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import type { TabGroupWorktreeSnapshot } from './useTabGroupItemProjections' export function useTabGroupActivationCommands({ @@ -138,5 +139,18 @@ export function useTabGroupActivationCommands({ [activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId] ) - return { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser } + const activateAgentSession = useCallback( + (tabId: string) => { + activateStructuredAgentSessionTab({ worktreeId, tabId }) + }, + [worktreeId] + ) + + return { + activateTerminal, + toggleTerminalPaneExpand, + activateEditor, + activateBrowser, + activateAgentSession + } } diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts index dd31114853b..921176604b0 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -22,6 +22,7 @@ export type TabGroupWorktreeSnapshot = { export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } +export type GroupAgentSessionItem = Tab & { contentType: 'agent-session' } type TerminalTabItem = TerminalTab & { unifiedTabId: string } @@ -118,6 +119,14 @@ export function useTabGroupItemProjections({ [groupTabs, worktreeState.browserTabs] ) + const agentSessionItems = useMemo( + () => + groupTabs.filter( + (item): item is GroupAgentSessionItem => item.contentType === 'agent-session' + ), + [groupTabs] + ) + const tabBarOrder = useMemo( () => (group?.tabOrder ?? []).map((itemId) => { @@ -132,5 +141,14 @@ export function useTabGroupItemProjections({ [group, groupTabs] ) - return { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder } + return { + group, + groupTabs, + activeTab, + terminalTabs, + editorItems, + browserItems, + agentSessionItems, + tabBarOrder + } } diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts new file mode 100644 index 00000000000..66f2d7681ee --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + closeBrowserTab: vi.fn(), + closeFile: vi.fn(), + closeStructuredAgentSession: vi.fn(), + closeTerminalTab: vi.fn(), + closeUnifiedTab: vi.fn(), + setActiveWorktree: vi.fn(), + toastError: vi.fn() +})) + +const store = vi.hoisted(() => ({ + activeWorktreeId: 'wt-1', + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + closeBrowserTab: mocks.closeBrowserTab, + closeFile: mocks.closeFile, + closeUnifiedTab: mocks.closeUnifiedTab, + openFiles: [], + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })), + setActiveWorktree: mocks.setActiveWorktree, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useCallback: (callback: T) => callback } +}) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign((selector: (state: typeof store) => unknown) => selector(store), { + getState: () => store + }) +})) + +vi.mock('../../store/slices/browser-webview-cleanup', () => ({ + destroyWorkspaceWebviews: vi.fn() +})) + +vi.mock('../editor/editor-autosave', () => ({ + requestEditorFileClose: vi.fn() +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: mocks.closeTerminalTab +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false) +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ + browserWorkspaceHasRemoteOwner: () => false +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: () => ({ kind: 'local' }) +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: mocks.closeStructuredAgentSession +})) + +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError } +})) + +import { useTabGroupTabCloseCommands } from './useTabGroupTabCloseCommands' + +const AGENT_TAB = { + id: 'agent-tab-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session' as const, + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.closeStructuredAgentSession.mockResolvedValue('closed') + mocks.callRuntimeRpc.mockResolvedValue({ ok: true }) +}) + +describe('structured agent-session close ordering', () => { + it('disposes the owner before asking the host to remove the canonical tab', async () => { + const order: string[] = [] + mocks.closeStructuredAgentSession.mockImplementation(async () => { + order.push('agent-close') + return 'closed' + }) + mocks.callRuntimeRpc.mockImplementation(async () => { + order.push('tab-close') + return { ok: true } + }) + mocks.closeUnifiedTab.mockImplementation(() => order.push('local-remove')) + + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + + await vi.waitFor(() => expect(order).toEqual(['agent-close', 'tab-close', 'local-remove'])) + }) + + it('keeps the tab available when owner disposal fails, so close can be retried', async () => { + mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable')) + + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts index eed462aa59a..5fe23b67927 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { toast } from 'sonner' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup' @@ -7,6 +8,20 @@ import { isWebRuntimeSessionActive } from '../../runtime/web-runtime-session' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' + +function reportStructuredSessionCloseError(error: unknown): void { + toast.error( + translate( + 'components.native-chat.structuredSessionCloseFailed', + 'Could not close this Codex chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) +} export function useTabGroupTabCloseCommands({ worktreeId, @@ -16,7 +31,6 @@ export function useTabGroupTabCloseCommands({ groupTabs: Tab[] }) { const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab) - const closeTab = useAppStore((state) => state.closeTab) const closeFile = useAppStore((state) => state.closeFile) const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) @@ -105,6 +119,29 @@ export function useTabGroupTabCloseCommands({ useAppStore.getState(), worktreeId ) + if (item.contentType === 'agent-session') { + // Why: the structured session lives on the host, so the local tab close must also + // retire the host's canonical row or it reappears on the next sync. + const target = getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: runtimeEnvironmentId + }) + void closeStructuredAgentSession(target, item.entityId) + .then(() => + callRuntimeRpc(target, 'session.tabs.close', { + worktree: toRuntimeWorktreeSelector(worktreeId), + tabId: `agent-session:${item.entityId}`, + reason: 'user' + }) + ) + .then(() => { + closeUnifiedTab(item.id) + if (!opts?.skipEmptyCheck) { + leaveWorktreeIfEmpty() + } + }) + .catch(reportStructuredSessionCloseError) + return + } if (item.contentType === 'terminal') { // Why: closeTerminalTab can defer behind a pin / running-process dialog, so the // empty check has to run on the actual close — never on cancel. @@ -155,6 +192,22 @@ export function useTabGroupTabCloseCommands({ useAppStore.getState(), worktreeId ) + if (item.contentType === 'agent-session') { + const target = getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: runtimeEnvironmentId + }) + void closeStructuredAgentSession(target, item.entityId) + .then(() => + callRuntimeRpc(target, 'session.tabs.close', { + worktree: toRuntimeWorktreeSelector(worktreeId), + tabId: `agent-session:${item.entityId}`, + reason: 'user' + }) + ) + .then(() => closeUnifiedTab(item.id)) + .catch(reportStructuredSessionCloseError) + continue + } if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: revoke local resume + hook authority before the host removes its canonical tab. // No running-process prompt: a bulk close of N busy tabs would be a modal storm. @@ -164,7 +217,7 @@ export function useTabGroupTabCloseCommands({ if (item.contentType === 'browser') { closeBrowserItem(item, runtimeEnvironmentId) } else if (item.contentType === 'terminal') { - closeTab(item.entityId) + closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true }) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { @@ -175,7 +228,7 @@ export function useTabGroupTabCloseCommands({ } } }, - [closeBrowserItem, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs, worktreeId] + [closeBrowserItem, closeEditorIfUnreferenced, closeUnifiedTab, groupTabs, worktreeId] ) return { closeItem, closeMany, leaveWorktreeIfEmpty } diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 87fb9e1f555..30bc640fd25 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ closeTab: vi.fn(), closeUnifiedTab: vi.fn(), closeWebRuntimeSessionTab: vi.fn(), + callRuntimeRpc: vi.fn(), + runtimeEnvironmentSupportsCapability: vi.fn(), createBrowserTab: vi.fn(), createEmptySplitGroup: vi.fn(), createTab: vi.fn(), @@ -77,6 +79,19 @@ vi.mock('../../runtime/web-runtime-session', () => ({ toHostSessionTabId: (tabId: string) => tabId })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId?: string | null + }) => + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' }, + runtimeEnvironmentSupportsCapability: mocks.runtimeEnvironmentSupportsCapability +})) + vi.mock('../../store/slices/browser-webview-cleanup', () => ({ destroyWorkspaceWebviews: mocks.destroyWorkspaceWebviews })) @@ -170,6 +185,8 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { status: 'failed', message: 'The workspace is not connected to a remote Orca host.' }) + mocks.callRuntimeRpc.mockResolvedValue({ ok: true }) + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) resetStore() vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0) @@ -197,6 +214,55 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', null) }) + it('closes the durable native owner from the real structured tab close action', async () => { + const agentTab = { + id: 'structured-agent-session-codex-session-1', + entityId: 'codex-session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + storeBox.state = { + ...storeBox.state, + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [agentTab] }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: agentTab.id, + tabOrder: [agentTab.id] + } + ] + } + } + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.closeItem(agentTab.id) + + await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(agentTab.id)) + expect(mocks.callRuntimeRpc.mock.calls).toEqual([ + [{ kind: 'local' }, 'agentSession.close', { sessionId: 'codex-session-1' }], + [ + { kind: 'local' }, + 'session.tabs.close', + { + worktree: 'id:wt-1', + tabId: 'agent-session:codex-session-1', + reason: 'user' + } + ] + ]) + }) + it('falls back to a local shell when the typed remote-create outcome is unavailable', async () => { mocks.createTab.mockReturnValue({ id: 'terminal-new' }) const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 34a75641430..bc8c676c2e5 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -45,8 +45,16 @@ export function useTabGroupWorkspaceModel({ const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle) const setTabColor = useAppStore((state) => state.setTabColor) - const { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder } = - useTabGroupItemProjections({ groupId, worktreeId, worktreeState }) + const { + group, + groupTabs, + activeTab, + terminalTabs, + editorItems, + browserItems, + agentSessionItems, + tabBarOrder + } = useTabGroupItemProjections({ groupId, worktreeId, worktreeState }) const { closeItem, closeMany, leaveWorktreeIfEmpty } = useTabGroupTabCloseCommands({ worktreeId, @@ -64,8 +72,13 @@ export function useTabGroupWorkspaceModel({ leaveWorktreeIfEmpty }) - const { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser } = - useTabGroupActivationCommands({ groupId, worktreeId, groupTabs, worktreeState }) + const { + activateTerminal, + toggleTerminalPaneExpand, + activateEditor, + activateBrowser, + activateAgentSession + } = useTabGroupActivationCommands({ groupId, worktreeId, groupTabs, worktreeState }) const creationCommands = useTabGroupCreationCommands({ groupId, worktreeId, worktreeState }) @@ -74,6 +87,7 @@ export function useTabGroupWorkspaceModel({ activeTab, browserItems, editorItems, + agentSessionItems, terminalTabs, tabBarOrder, groupTabs, @@ -82,6 +96,7 @@ export function useTabGroupWorkspaceModel({ focusGroup: () => { focusGroup(worktreeId, groupId) }, + activateAgentSession, activateBrowser, activateEditor, activateTerminal, diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 6deef0b5864..bd2ca330f5c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -77,9 +77,6 @@ function renderMenu(overrides: Record = {}): string { canContinueAgentSessionInNewSession: false, onContinueAgentSessionInNewSession: vi.fn(), onForkAgentSession: vi.fn(), - canToggleNativeChat: false, - isNativeChatView: false, - onToggleNativeChat: vi.fn(), onCopyAgentSessionContext: vi.fn(), quickCommandHosts: [ { hostId: 'local' as const, label: 'Local Linux', repoCommands: [], globalCommands: [] } @@ -143,6 +140,12 @@ describe('TerminalContextMenu', () => { expect(onContinueAgentSessionInNewSession).toHaveBeenCalledTimes(1) }) + it('does not expose a native/terminal view switch in the terminal menu', () => { + renderMenu() + + expect(items.list.some((item) => childrenText(item.children).includes('Switch to'))).toBe(false) + }) + it('shows one shortcut per terminal menu action on Windows', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 73a9d508f4c..178ab0a3247 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -6,13 +6,11 @@ import { Eraser, GitFork, Maximize2, - MessageSquare, Minimize2, PanelBottomClose, PanelsTopLeft, PanelRightClose, Pencil, - SquareTerminal, TextSelect, X } from 'lucide-react' @@ -30,7 +28,6 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import type { KeybindingOverrides } from '../../../../shared/keybindings' import { translate } from '@/i18n/i18n' -import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut' import { AgentSessionContinuationMenuItem } from './AgentSessionContinuationMenuItem' import type { TerminalQuickCommandMenuHost } from '@/hooks/use-terminal-quick-command-hosts' import { TerminalQuickCommandsSubmenu } from './TerminalQuickCommandsSubmenu' @@ -56,9 +53,6 @@ type TerminalContextMenuProps = { canContinueAgentSessionInNewSession: boolean onContinueAgentSessionInNewSession: () => void onForkAgentSession: () => void - canToggleNativeChat: boolean - isNativeChatView: boolean - onToggleNativeChat: () => void onCopyAgentSessionContext: () => void quickCommandHosts: TerminalQuickCommandMenuHost[] quickCommandHostLoadFailed: boolean @@ -95,9 +89,6 @@ export default function TerminalContextMenu({ canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onForkAgentSession, - canToggleNativeChat, - isNativeChatView, - onToggleNativeChat, onCopyAgentSessionContext, quickCommandHosts, quickCommandHostLoadFailed, @@ -124,8 +115,7 @@ export default function TerminalContextMenu({ expand: formatPrimaryShortcutLabel('terminal.expandPane', keybindings), setTitle: formatPrimaryShortcutLabel('terminal.setTitle', keybindings), clearPaneTitle: formatPrimaryShortcutLabel('terminal.clearPaneTitle', keybindings), - close: formatPrimaryShortcutLabel('terminal.closePane', keybindings), - nativeChat: nativeChatToggleShortcutLabel(isMacPlatform()) + close: formatPrimaryShortcutLabel('terminal.closePane', keybindings) }), [keybindings] ) @@ -215,21 +205,6 @@ export default function TerminalContextMenu({ 'Copy Context' )} - {canToggleNativeChat ? ( - - {isNativeChatView ? : } - {isNativeChatView - ? translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to chat view' - )} - {shortcuts.nativeChat} - - ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2b58732f0ea..6157af7d36d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -12,6 +12,7 @@ import { import { useShallow } from 'zustand/react/shallow' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { IDisposable } from '@xterm/xterm' import { useAppStore } from '../../store' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' @@ -522,13 +523,28 @@ function TerminalPane( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.id ) + const structuredSessionAgent = useAppStore( + (store) => + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.agentSessionAgent + ) const isChatViewMode = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) ?.viewMode === 'chat' ) + const structuredSessionId = useAppStore( + (store) => + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.structuredSessionId ?? null + ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) const effectiveChatViewMode = nativeChatEnabled && isChatViewMode + const chatPaneDispatchStatus = useAppStore((store) => + chatLeafId + ? store.agentStatusByPaneKey[makePaneKey(tabId, chatLeafId)]?.orchestration?.dispatchStatus + : undefined + ) const unifiedTabLabel = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.label @@ -538,9 +554,12 @@ function TerminalPane( ) // Carry each leaf's agent identity, not just "an agent exists", so the gate can reject unsupported agents; scoped to this tab's panes. const tabAgentTypeByLeaf = useAppStore((store) => - selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) + selectTerminalTabAgentTypesByLeaf( + store.agentStatusByPaneKey, + tabId, + store.paneForegroundAgentByPaneKey + ) ) - const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) const terminalTab = useAppStore((store) => @@ -616,13 +635,18 @@ function TerminalPane( contentType: 'terminal', launchAgent: detectedAgent ? null : launchAgent, detectedAgent, - resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), + // A structured handoff keeps the durable provider identity even when the + // foreground hook has not republished agent status after returning to TUI. + resolvedAgent: detectedAgent + ? null + : ((structuredSessionAgent as TuiAgent | null) ?? resolveTitleAgentForLeaf(leafId)), nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, nativeChatEnabled, + structuredSessionAgent, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, getNativeChatLeafIds, @@ -656,55 +680,31 @@ function TerminalPane( activeLeafId, chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId), activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), - chatLeafHasConfirmedAgentExit: true + chatLeafHasConfirmedAgentExit: true, + structuredSessionId }) ) }, - [applyNativeChatLeafRoute, chatLeafId, isChatEligibleForLeaf, isChatViewMode] + [ + applyNativeChatLeafRoute, + chatLeafId, + isChatEligibleForLeaf, + isChatViewMode, + structuredSessionId + ] ) useEffect(() => { // Why: transport callbacks must observe only committed chat ownership; render work can be replayed/discarded under concurrent React. onAgentExitedRef.current = handleConfirmedAgentExit }, [handleConfirmedAgentExit]) - const canToggleChatForLeaf = useCallback( - (leafId: string | null): boolean => { - // Scope the "always allow toggling back" rule to the leaf showing chat; must not make an unsupported sibling look eligible. - const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId - return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) - }, - [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] - ) - const toggleNativeChatForLeaf = useCallback( - (leafId: string) => { - if (!unifiedTabId) { - return - } - if (effectiveChatViewMode && chatLeafId === leafId) { - setChatLeafId(null) - toggleTabViewMode(unifiedTabId) - return - } - setChatLeafId(leafId) - if (!effectiveChatViewMode) { - toggleTabViewMode(unifiedTabId) - } - }, - [unifiedTabId, effectiveChatViewMode, chatLeafId, toggleTabViewMode] - ) - const handleToggleNativeChat = useCallback(() => { - const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null - if (!activeLeafId) { - return - } - toggleNativeChatForLeaf(activeLeafId) - }, [toggleNativeChatForLeaf]) // Stable identity: this reaches the session-option surface's useMemo deps, so an // inline arrow would rebuild the surface on every TerminalPane render. const switchNativeChatToTerminal = useCallback(() => { - if (chatLeafId) { - toggleNativeChatForLeaf(chatLeafId) + if (chatLeafId && unifiedTabId) { + setChatLeafId(null) + setTabViewMode(unifiedTabId, 'terminal') } - }, [chatLeafId, toggleNativeChatForLeaf]) + }, [chatLeafId, setChatLeafId, setTabViewMode, unifiedTabId]) const readNativeChatTerminalScreen = useCallback((): string | null => { if (!chatLeafId) { return null @@ -743,12 +743,19 @@ function TerminalPane( const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState< Map >(() => new Map()) + const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) const [issueCommandSplit] = useState( () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] ) const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit) + const settleTabStartupCommand = useCallback(() => { + if (startup) { + consumeTabStartupCommand(tabId, startup) + } + }, [consumeTabStartupCommand, startup, tabId]) + useLayoutEffect(() => { if (isVisible && shouldMeasureHiddenStartup) { // Why: hidden startup measurement is first-launch only; keeping it past first visibility would let inactive tabs refit and SIGWINCH. @@ -1400,6 +1407,7 @@ function TerminalPane( setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound: settleTabStartupCommand, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -2649,14 +2657,6 @@ function TerminalPane( return manager.getActivePane()?.leafId ?? null }, [contextMenu.menuPaneId]) const contextMenuLeafId = getContextMenuLeafId() - const contextMenuIsChatView = effectiveChatViewMode && contextMenuLeafId === chatLeafId - const handleContextMenuToggleNativeChat = useCallback(() => { - const leafId = getContextMenuLeafId() - if (!leafId) { - return - } - toggleNativeChatForLeaf(leafId) - }, [getContextMenuLeafId, toggleNativeChatForLeaf]) const getMobileOwnedTerminalPtyIds = useCallback((): string[] => { const ptyIds = new Set(getMobileFitOverridePtyIds()) @@ -2954,7 +2954,8 @@ function TerminalPane( chatLeafId, activeLeafId, chatLeafStillMounted, - activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), + structuredSessionId }) applyNativeChatLeafRoute(route) }, [ @@ -2963,7 +2964,8 @@ function TerminalPane( activePane?.leafId, chatLeafStillMounted, applyNativeChatLeafRoute, - isChatEligibleForLeaf + isChatEligibleForLeaf, + structuredSessionId ]) const chatPane = isChatViewMode && chatLeafId @@ -2979,9 +2981,8 @@ function TerminalPane( leafId: chatPane?.leafId ?? null, leafIds: getNativeChatLeafIds() }) - const activePaneIsChatLeaf = Boolean( - isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId - ) + const structuredChatAgent = structuredSessionAgent ?? chatPaneResolvedAgent ?? chatPaneLaunchAgent + const structuredChatTarget = useMemo(() => ({ kind: 'local' as const }), []) // A split can host different agents, so continuation resolves the specific leaf before using tab-wide hints. const resolveAgentForLeaf = (leafId: string | null): string | null => { const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null @@ -3003,9 +3004,6 @@ function TerminalPane( const contextMenuCanContinueInNewSession = canContinueAgentSessionInNewSession( resolveAgentForLeaf(contextMenuLeafId) ) - // Each toggle gates on its own leaf (header=active, menu=opened-over), so mixed splits show it only where chat can render. - const activePaneCanToggleChat = canToggleChatForLeaf(activePane?.leafId ?? null) - const contextMenuCanToggleChat = canToggleChatForLeaf(contextMenuLeafId) return ( <>
{effectiveChatViewMode && chatPane?.container ? createPortal( -
- contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), - onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), - canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, - onEqualizePaneSizes: () => - contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), - canExpandPane: managedPanes.length > 1, - isPaneExpanded: expandedPaneId === chatPane.id, - onToggleExpand: () => - contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), - canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( - resolveAgentForLeaf(chatPane.leafId) - ), - onContinueAgentSessionInNewSession: () => - contextMenu.runForPane( - chatPane.id, - contextMenu.onContinueAgentSessionInNewSession +
+ {structuredSessionId && structuredChatAgent ? ( + + ) : ( + + contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), + onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), + canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, + onEqualizePaneSizes: () => + contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), + canExpandPane: managedPanes.length > 1, + isPaneExpanded: expandedPaneId === chatPane.id, + onToggleExpand: () => + contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), + canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( + resolveAgentForLeaf(chatPane.leafId) ), - onForkAgentSession: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), - onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), - onCopyTerminalId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), - onCopyPaneId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), - canClosePane: managedPanes.length > 1, - onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) - }} - /> + onContinueAgentSessionInNewSession: () => + contextMenu.runForPane( + chatPane.id, + contextMenu.onContinueAgentSessionInNewSession + ), + onForkAgentSession: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), + onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), + onCopyTerminalId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), + onCopyPaneId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canClosePane: managedPanes.length > 1, + onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) + }} + orchestrationDispatchStatus={chatPaneDispatchStatus} + /> + )}
, chatPane.container, `native-chat-${tabId}-${chatPane.leafId}` @@ -3204,9 +3217,6 @@ function TerminalPane( canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession} onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession} onForkAgentSession={() => void contextMenu.onForkAgentSession()} - canToggleNativeChat={contextMenuCanToggleChat} - isNativeChatView={contextMenuIsChatView} - onToggleNativeChat={handleContextMenuToggleNativeChat} onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()} quickCommandHosts={visibleQuickCommandHosts} quickCommandHostLoadFailed={quickCommandHostLoadFailed} @@ -3278,9 +3288,6 @@ function TerminalPane( hiddenStartupStyle={hiddenStartupStyle} managerRef={managerRef} paneTransportsRef={paneTransportsRef} - canToggleNativeChat={activePaneCanToggleChat} - isChatViewMode={activePaneIsChatLeaf} - onToggleNativeChat={handleToggleNativeChat} canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession} onContinueAgentSessionInNewSession={(pane) => contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index a9ed8249179..6b246701c43 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -1,11 +1,5 @@ import type { CSSProperties, RefObject } from 'react' -import { - MessageSquare, - MessageSquarePlus, - SquareSplitVertical, - SquareTerminal, - X -} from 'lucide-react' +import { MessageSquarePlus, SquareSplitVertical, X } from 'lucide-react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -42,14 +36,6 @@ type TerminalPaneHeaderOverlayProps = { hiddenStartupStyle: CSSProperties managerRef: RefObject paneTransportsRef: RefObject> - /** When true, this pane can toggle the native chat view; renders a chat/terminal - * toggle as the first button in the pane header actions row (beside split/close). - * The caller gates it to the active pane to avoid duplicating it across splits. */ - canToggleNativeChat?: boolean - /** True when the active pane is currently showing the native chat view. */ - isChatViewMode?: boolean - /** Flip the active pane between the terminal and the native chat view. */ - onToggleNativeChat?: () => void canContinueAgentSessionInNewSession?: boolean onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void @@ -85,9 +71,6 @@ export default function TerminalPaneHeaderOverlay({ hiddenStartupStyle, managerRef, paneTransportsRef, - canToggleNativeChat, - isChatViewMode, - onToggleNativeChat, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onSplitPane, @@ -272,47 +255,6 @@ export default function TerminalPaneHeaderOverlay({ ) : null} - {canToggleNativeChat && isActivePane ? ( - - - - - - {isChatViewMode - ? translate('components.native-chat.toggle.showTerminal', 'Show terminal') - : translate('components.native-chat.toggle.showChat', 'Show chat view')} - - - ) : null} {showAlwaysOnHeaders && showSplitButton ? ( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 2cb20783f53..79d0f42a14e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -8,7 +8,6 @@ import { type ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' -import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { TerminalOverlaySlot } from './TerminalOverlaySlot' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' @@ -60,8 +59,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel) - useNativeChatToggleShortcut(worktreeId, isWorktreeActive) - const leaveWorktreeIfEmpty = useCallback(() => { const state = useAppStore.getState() if (state.activeWorktreeId !== worktreeId) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 0f36829835b..acbced67212 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -454,4 +454,32 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, 'echo hi\r') expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') }) + + it('settles a queued startup only after the pane binds its spawned PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-resume') + transportFactoryQueue.push(transport) + const onStartupBound = vi.fn() + const startup = { + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup, onStartupBound }) as never + ) + + expect(onStartupBound).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject(startup) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('pty-resume') + onPtySpawn?.('pty-resume') + + expect(onStartupBound).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 1fc9411cdfe..54030f10d77 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -117,6 +117,8 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after this pane owns a concrete PTY. */ + onStartupBound?: () => void deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void /** Records a DECSET 2031 subscription seen through main's '2031-subscribe' * fact (paneMode2031 + the mode at subscribe time) so later theme flips push diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts index fe449dadb47..8fbb4c613be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -74,6 +74,11 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo session.deps.updateTabPtyId(session.deps.tabId, ptyId) } } + if (session.paneStartup && !session.startupPtyBound) { + // Settles the captured one-shot startup only after this pane owns a concrete PTY. + session.startupPtyBound = true + session.deps.onStartupBound?.() + } if (options.seedInitialAgentStatus) { session.applyInitialAgentStatus() } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index da14fb4b9b6..636f126133d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -9,6 +9,7 @@ import { isTerminalInputQuarantined } from './terminal-input-quarantine' const mocks = vi.hoisted(() => ({ remountTerminalTabForRecovery: vi.fn<(tabId: string) => boolean>(() => true), + getTab: vi.fn<() => { viewMode?: 'terminal' | 'chat' } | null>(() => null), recordRendererCrashBreadcrumb: vi.fn(), hasPty: vi.fn<(id: string) => Promise>(async () => true) })) @@ -16,7 +17,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ - remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery + remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery, + getTab: mocks.getTab }) } })) @@ -29,6 +31,8 @@ beforeEach(() => { _resetTerminalPaneRecoveryForTests() mocks.remountTerminalTabForRecovery.mockClear() mocks.remountTerminalTabForRecovery.mockReturnValue(true) + mocks.getTab.mockClear() + mocks.getTab.mockReturnValue(null) mocks.recordRendererCrashBreadcrumb.mockClear() mocks.hasPty.mockClear() mocks.hasPty.mockResolvedValue(true) @@ -45,6 +49,20 @@ afterEach(() => { }) describe('requestTerminalPaneRecovery', () => { + it('does not remount a terminal surface hidden behind native chat', async () => { + mocks.getTab.mockReturnValue({ viewMode: 'chat' }) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + expect(mocks.hasPty).not.toHaveBeenCalled() + }) + it('remounts the tab and records a breadcrumb for a certified-dead pipeline', async () => { const result = await requestTerminalPaneRecovery({ tabId: 'tab-1', diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 3db158d805a..4c81e6f3c66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -210,6 +210,12 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro if (!isCurrentTerminalRecoveryRequest(request)) { return false } + // A terminal-backed tab is intentionally hidden while native chat owns the + // provider. Late xterm callbacks from that hidden surface must not remount + // the tab and race the handoff's owner transition. + if (useAppStore.getState().getTab?.(request.tabId)?.viewMode === 'chat') { + return false + } const budget = recoveryBudget(request.tabId, Date.now()) if (!budget.allowed) { if (shouldScheduleRecoveryRetry(request, budget)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts index bf000ffc697..d1714b19ed8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -71,4 +71,30 @@ describe('createTerminalTabAgentTypeSelector', () => { expect(select(state, 'tab-1')).toEqual({}) expect(select(state, 'malformed')).toEqual({}) }) + + it('uses a live foreground process until hook identity arrives', () => { + const select = createTerminalTabAgentTypeSelector() + const foreground = { + 'tab-1:leaf-a': { + agent: 'codex' as const, + shellForeground: false, + routingTrusted: true + } + } + + expect(select({}, 'tab-1', foreground)).toEqual({ 'leaf-a': 'codex' }) + expect(select({ 'tab-1:leaf-a': entry('claude') }, 'tab-1', foreground)).toEqual({ + 'leaf-a': 'claude' + }) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], shellForeground: true } + }) + ).toEqual({}) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], routingRevoked: true } + }) + ).toEqual({}) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index 1b7693b45ab..f14e96763e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -1,4 +1,5 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' export type TerminalTabAgentTypeState = Record export type TerminalTabAgentTypesByLeaf = Readonly> @@ -8,6 +9,9 @@ type SelectorDependencies = { } const EMPTY_AGENT_TYPES_BY_LEAF: TerminalTabAgentTypesByLeaf = Object.freeze({}) +const EMPTY_FOREGROUND_AGENT_BY_PANE_KEY: Record = Object.freeze( + {} +) function reuseRecordIfEqual( previous: TerminalTabAgentTypesByLeaf | undefined, @@ -25,14 +29,19 @@ function reuseRecordIfEqual( export function createTerminalTabAgentTypeSelector( dependencies: SelectorDependencies = {} -): (state: TerminalTabAgentTypeState, tabId: string) => TerminalTabAgentTypesByLeaf { +): ( + state: TerminalTabAgentTypeState, + tabId: string, + foreground?: Record +) => TerminalTabAgentTypesByLeaf { let cachedState: TerminalTabAgentTypeState | null = null + let cachedForeground: Record | null = null let cachedByTabId = new Map() - return (state, tabId) => { + return (state, tabId, foreground = EMPTY_FOREGROUND_AGENT_BY_PANE_KEY) => { // Why: production writes replace this map. Its identity lets unrelated // Zustand notifications skip the global scan entirely. - if (state !== cachedState) { + if (state !== cachedState || foreground !== cachedForeground) { const previousByTabId = cachedByTabId const nextByTabId = new Map>() for (const [paneKey, entry] of Object.entries(state)) { @@ -53,6 +62,23 @@ export function createTerminalTabAgentTypeSelector( nextByTabId.set(entryTabId, { [leafId]: entry.agentType }) } } + for (const [paneKey, entry] of Object.entries(foreground)) { + if (!entry.agent || entry.shellForeground || entry.routingRevoked) { + continue + } + const separator = paneKey.indexOf(':') + if (separator <= 0) { + continue + } + const entryTabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + const byLeaf = nextByTabId.get(entryTabId) + if (byLeaf) { + byLeaf[leafId] ??= entry.agent + } else { + nextByTabId.set(entryTabId, { [leafId]: entry.agent }) + } + } const stabilizedByTabId = new Map() for (const [entryTabId, byLeaf] of nextByTabId) { @@ -63,6 +89,7 @@ export function createTerminalTabAgentTypeSelector( } cachedByTabId = stabilizedByTabId cachedState = state + cachedForeground = foreground } return cachedByTabId.get(tabId) ?? EMPTY_AGENT_TYPES_BY_LEAF } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 0f033c1c3c8..473d8551346 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -329,6 +329,8 @@ type UseTerminalPaneLifecycleDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after a pane owns a concrete PTY. */ + onStartupBound?: () => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setExpandedPane: (paneId: number | null) => void @@ -722,6 +724,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -965,6 +968,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, deferPtyInput: (paneId, data, forward) => { const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression if (!suppression) { diff --git a/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts b/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts new file mode 100644 index 00000000000..12edc1e6ecf --- /dev/null +++ b/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts @@ -0,0 +1,64 @@ +import type { Tab } from '../../../../shared/tab-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' + +const STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const + +export function structuredTerminalSessionId( + unifiedTabs: readonly Tab[] | undefined, + terminalTabId: string +): string | null { + return ( + unifiedTabs?.find( + (tab) => + tab.contentType === 'terminal' && tab.entityId === terminalTabId && tab.viewMode === 'chat' + )?.structuredSessionId ?? null + ) +} + +export async function closeStructuredTerminalSessionWithRetry( + target: RuntimeClientTarget, + sessionId: string +): Promise { + for (const [attempt, delayMs] of STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS.entries()) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + try { + await closeStructuredAgentSession(target, sessionId) + return true + } catch (error) { + if (attempt === STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS.length - 1) { + console.warn('[structured-agent-session] terminal close disposal failed', { + sessionId, + error + }) + } + } + } + return false +} + +export function disposeStructuredTerminalSession({ + unifiedTabs, + terminalTabId, + target, + reason +}: { + unifiedTabs: readonly Tab[] | undefined + terminalTabId: string + target: RuntimeClientTarget + reason: TerminalTabCloseReason +}): void { + if (reason === 'pty-exit') { + return + } + const structuredSessionId = structuredTerminalSessionId(unifiedTabs, terminalTabId) + if (!structuredSessionId) { + return + } + // Closing is idempotent; a short retry window covers a dropped renderer/host request after the + // terminal surface has already been removed. + void closeStructuredTerminalSessionWithRetry(target, structuredSessionId) +} diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index 990d00cd87a..051c2533518 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -1,4 +1,6 @@ -export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'simulator' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' + +export type TabCycleType = WorkspaceVisibleTabType export type TypeCyclableTab = { type: TabCycleType diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts index ea7a3868908..91adf10c4ae 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts @@ -37,6 +37,7 @@ function baseState(overrides: Record = {}): Record ({ renderableTabCount: 0 })), closeTab: vi.fn(), closeUnifiedTab: vi.fn(), setActiveFile: vi.fn(), diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts new file mode 100644 index 00000000000..caf15791365 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + closeStructuredAgentSession: vi.fn(), + closeTab: vi.fn(), + getState: vi.fn(), + isWebRuntimeSessionActive: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: vi.fn(), + isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive, + toHostSessionTabId: mocks.toHostSessionTabId +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: mocks.closeStructuredAgentSession +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +beforeEach(() => { + vi.clearAllMocks() + mocks.closeStructuredAgentSession.mockResolvedValue('closed') + mocks.isWebRuntimeSessionActive.mockReturnValue(false) +}) + +describe('structured session disposal from terminal close', () => { + it('disposes the native owner when an adopted TUI tab closes from chat view', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1') + + await vi.waitFor(() => + expect(mocks.closeStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-1' + ) + ) + }) + + it('keeps natural adopted-TUI exits on the ownership reconciliation path', () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1', { reason: 'pty-exit' }) + + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + }) + + it('retries a transient structured-owner close after the tab is removed', async () => { + vi.useFakeTimers() + try { + mocks.closeStructuredAgentSession + .mockRejectedValueOnce(new Error('host unavailable')) + .mockResolvedValueOnce('closed') + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1') + await vi.advanceTimersByTimeAsync(250) + expect(mocks.closeStructuredAgentSession).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts new file mode 100644 index 00000000000..bc6dc7b2604 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' + +// Why: drives the real closeTerminalTab orchestrator against the real store so the +// unified close contract (MRU/neighbor successor, renderable-count deactivation gate) +// is exercised end to end. Slice-level tests pass without the orchestrator fix and +// would be vacuous for these regressions. + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => null), + resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null) +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: vi.fn(() => Promise.resolve()) +})) + +const { createTabsSliceMockApi } = await import('@/store/slices/tabs-slice-test-harness') +createTabsSliceMockApi() + +const { createTestStore, makeTab, makeTabGroup, makeUnifiedTab, makeWorktree, seedStore } = + await import('@/store/slices/store-test-helpers') +const store = createTestStore() + +vi.mock('@/store', () => ({ useAppStore: store })) + +const { closeTerminalTab } = await import('./terminal-tab-actions') + +const GIT_WT = 'repo1::/tmp/wt1' +const FOLDER_WT = 'folder:folder-1' +const GROUP = 'group-1' + +function seedWorktreeWithTabs( + worktreeId: string, + args: { + terminalIds: string[] + /** Unified group order; entries are unified tab ids ("u-" + terminal id, or the chat id). */ + groupOrder: string[] + /** MRU stack, most recent last. */ + recentTabIds: string[] + activeUnifiedTabId: string + activeTerminalId: string + includeChatTab?: boolean + } +): void { + const chatTab = makeUnifiedTab({ + id: 'chat-1', + entityId: 'codex-session-1', + groupId: GROUP, + worktreeId, + contentType: 'agent-session', + label: 'Codex Chat' + }) + const unifiedByTabId = new Map( + args.terminalIds.map((terminalId) => [ + `u-${terminalId}`, + makeUnifiedTab({ + id: `u-${terminalId}`, + entityId: terminalId, + groupId: GROUP, + worktreeId, + contentType: 'terminal' + }) + ]) + ) + if (args.includeChatTab !== false) { + unifiedByTabId.set(chatTab.id, chatTab) + } + // Why: keep the unified array in group order so insertion-order assertions are real. + const unifiedTabs = args.groupOrder.flatMap((tabId) => { + const tab = unifiedByTabId.get(tabId) + return tab ? [tab] : [] + }) + seedStore(store, { + activeWorktreeId: worktreeId, + worktreesByRepo: { + repo1: [makeWorktree({ id: GIT_WT, repoId: 'repo1', path: '/tmp/wt1' })] + }, + tabsByWorktree: { + [worktreeId]: args.terminalIds.map((terminalId) => makeTab({ id: terminalId, worktreeId })) + }, + unifiedTabsByWorktree: { + [worktreeId]: unifiedTabs + }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: GROUP, + worktreeId, + activeTabId: args.activeUnifiedTabId, + tabOrder: args.groupOrder, + recentTabIds: args.recentTabIds + }) + ] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId: GROUP } }, + activeGroupIdByWorktree: { [worktreeId]: GROUP }, + activeTabId: args.activeTerminalId, + activeTabIdByWorktree: { [worktreeId]: args.activeTerminalId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [worktreeId]: 'terminal' }, + openFiles: [], + browserTabsByWorktree: {} + } as Partial) +} + +function group(worktreeId: string) { + return store.getState().groupsByWorktree[worktreeId]?.find((entry) => entry.id === GROUP) +} + +beforeEach(() => { + store.setState({ + activeWorktreeId: null, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + openFiles: [] + } as Partial) +}) + +describe('closeTerminalTab unified close contract', () => { + it('keeps the git worktree active and focuses the chat tab when the last terminal closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'chat-1'], + recentTabIds: ['chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(state.activeTabType).toBe('agent-session') + expect(group(GIT_WT)?.activeTabId).toBe('chat-1') + expect(state.unifiedTabsByWorktree[GIT_WT]?.map((tab) => tab.id)).toEqual(['chat-1']) + }) + + it('keeps the folder workspace active and focuses the chat tab when the last terminal closes', () => { + seedWorktreeWithTabs(FOLDER_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'chat-1'], + recentTabIds: ['chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(FOLDER_WT) + expect(state.activeTabType).toBe('agent-session') + expect(group(FOLDER_WT)?.activeTabId).toBe('chat-1') + }) + + it('falls back to the most recent chat tab, not the next terminal, when closing among two terminals', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1', 'term-2'], + groupOrder: ['u-term-1', 'chat-1', 'u-term-2'], + recentTabIds: ['u-term-2', 'chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(group(GIT_WT)?.activeTabId).toBe('chat-1') + expect(state.activeTabType).toBe('agent-session') + // Why: insertion order must survive the close — only the closed tab drops out. + expect(state.unifiedTabsByWorktree[GIT_WT]?.map((tab) => tab.id)).toEqual([ + 'chat-1', + 'u-term-2' + ]) + expect(group(GIT_WT)?.tabOrder).toEqual(['chat-1', 'u-term-2']) + }) + + it('still deactivates the worktree when the last renderable tab closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1'], + recentTabIds: ['u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1', + includeChatTab: false + }) + + closeTerminalTab('term-1') + + expect(store.getState().activeWorktreeId).toBeNull() + }) + + it('lands on an open editor tab instead of deactivating when the last terminal closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'editor-1'], + recentTabIds: ['editor-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1', + includeChatTab: false + }) + store.setState((state) => ({ + openFiles: [ + { + id: 'file-1', + worktreeId: GIT_WT, + filePath: 'file-1', + relativePath: 'file.ts', + language: 'typescript', + isDirty: false, + mode: 'edit' as const + } + ], + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [GIT_WT]: [ + ...(state.unifiedTabsByWorktree[GIT_WT] ?? []), + makeUnifiedTab({ + id: 'editor-1', + entityId: 'file-1', + groupId: GROUP, + worktreeId: GIT_WT, + contentType: 'editor' + }) + ] + } + })) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(state.activeTabType).toBe('editor') + expect(state.activeFileId).toBe('file-1') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 4b7557ba057..91e59845722 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -441,6 +441,7 @@ describe('closeTerminalTab', () => { activeTabId: 'terminal-entity-1', openFiles: [], browserTabsByWorktree: {}, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), closeTab, closeUnifiedTab, setActiveTab: vi.fn(), @@ -453,7 +454,7 @@ describe('closeTerminalTab', () => { expect(closeUnifiedTab).not.toHaveBeenCalled() }) - it('activates the next unified terminal tab when closing the active unified-only tab', () => { + it('defers successor selection for unified terminal tabs to the unified close contract', () => { const closeTab = vi.fn() const closeUnifiedTab = vi.fn() const setActiveTab = vi.fn() @@ -504,7 +505,10 @@ describe('closeTerminalTab', () => { closeTerminalTab('terminal-entity-1') - expect(setActiveTab).toHaveBeenCalledWith('terminal-entity-2') + // Why: a unified terminal must not pre-pick a terminal-only successor — the + // store's closeUnifiedTab owns the MRU/neighbor repair (which may land on an + // agent-session tab); terminal-tab-actions-unified-close.test.ts covers it. + expect(setActiveTab).not.toHaveBeenCalled() expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined }) expect(closeUnifiedTab).not.toHaveBeenCalled() }) @@ -573,6 +577,7 @@ describe('closeTerminalTab', () => { activeTabId: 'pinned-entity-1', openFiles: [], browserTabsByWorktree: {}, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), closeTab: vi.fn(), closeUnifiedTab: vi.fn(), setActiveTab: vi.fn(), diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 8216696d217..49bc3d11fef 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -9,12 +9,19 @@ import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { translate } from '@/i18n/i18n' import { guardPinnedTabClose, isUnifiedTabPinned, resolvePinnedTabLabel, shouldConfirmPinnedTabClose } from '@/store/pinned-tab-close-guard' +import { + closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession, + structuredTerminalSessionId +} from './structured-terminal-session-disposal' +import { toast } from 'sonner' import type { TerminalTabCloseReason, TerminalTabRetirementPlan @@ -51,6 +58,8 @@ export function closeTerminalTab( skipRunningProcessConfirm?: boolean captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean + /** Internal re-entry after the structured provider close is proven. */ + structuredSessionCloseConfirmed?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan precomputedCloseState?: PrecomputedTerminalCloseState onClosed?: () => void @@ -131,6 +140,59 @@ export function closeTerminalTab( } const runtimeEnvironmentId = worktreeRoute.runtimeEnvironmentId + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[owningWorktreeId], + terminalTabId + ) + if ( + structuredSessionId && + options?.reason !== 'pty-exit' && + options?.structuredSessionCloseConfirmed !== true + ) { + const target = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + void closeStructuredTerminalSessionWithRetry(target, structuredSessionId).then((closed) => { + if (!closed) { + toast.error( + translate( + 'components.native-chat.structuredSessionCloseFailed', + 'Could not close this Codex chat' + ), + { + description: translate( + 'components.native-chat.structuredSessionCloseFailedDescription', + 'The terminal stayed open so the provider remains recoverable.' + ) + } + ) + options?.onCancel?.() + return + } + closeTerminalTab(tabId, { + ...options, + force: true, + skipRunningProcessConfirm: true, + structuredSessionCloseConfirmed: true + }) + }) + return + } + const retireStructuredSession = (): void => { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + const target = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + if (options?.structuredSessionCloseConfirmed === true) { + return + } + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[owningWorktreeId], + terminalTabId, + target, + reason: closeReason + }) + } if (runtimeEnvironmentId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { if (options?.reason === 'pty-exit') { // Why: stream exit is not host-tab closure; the HUB snapshot decides whether reconnect restores or removes this tab. @@ -189,6 +251,7 @@ export function closeTerminalTab( } : {}) }) + retireStructuredSession() options?.onClosed?.() return } @@ -198,42 +261,19 @@ export function closeTerminalTab( : getWorktreeTerminalTabIds(state, owningWorktreeId) const terminalCountBeforeClose = precomputedCloseState?.terminalCountBeforeClose ?? currentTerminalTabIds!.length - if (terminalCountBeforeClose <= 1) { - closeLocalTerminalTabState(terminalTabId, { - reason: options?.reason, - ...(options?.captureRecentlyClosed !== undefined - ? { captureRecentlyClosed: options.captureRecentlyClosed } - : {}), - ...(options?.localPtyTeardownOwnedExternally - ? { localPtyTeardownOwnedExternally: true } - : {}), - ...(options?.precomputedRetirementPlan - ? { precomputedRetirementPlan: options.precomputedRetirementPlan } - : {}) - }) - if (state.activeWorktreeId === owningWorktreeId) { - // Why: only deactivate the worktree when no tabs of any kind remain. - // Editor files are a separate tab type; closing the last terminal tab - // should switch to the editor view instead of tearing down the workspace. - const worktreeFile = state.openFiles.find((f) => f.worktreeId === owningWorktreeId) - if (worktreeFile) { - state.setActiveFile(worktreeFile.id) - state.setActiveTabType('editor') - } else { - const browserTab = (state.browserTabsByWorktree?.[owningWorktreeId] ?? [])[0] - if (browserTab) { - state.setActiveBrowserTab(browserTab.id) - state.setActiveTabType('browser') - } else { - state.setActiveWorktree(null) - } - } - } - options?.onClosed?.() - return - } - - if (state.activeWorktreeId === owningWorktreeId && terminalTabId === state.activeTabId) { + // Why: a terminal with a unified row must leave successor choice to closeUnifiedTab's + // MRU/neighbor repair — a terminal-only pre-pick skips agent-session/simulator neighbors + // and re-stamps the group active before the canonical repair can run. + const hasUnifiedRow = (state.unifiedTabsByWorktree?.[owningWorktreeId] ?? []).some( + (tab) => + tab.contentType === 'terminal' && (tab.entityId === terminalTabId || tab.id === terminalTabId) + ) + if ( + !hasUnifiedRow && + terminalCountBeforeClose > 1 && + state.activeWorktreeId === owningWorktreeId && + terminalTabId === state.activeTabId + ) { const currentIndex = currentTerminalTabIds?.indexOf(terminalTabId) ?? -1 const nextTabId = precomputedCloseState ? precomputedCloseState.nextTerminalTabId @@ -253,5 +293,32 @@ export function closeTerminalTab( ? { precomputedRetirementPlan: options.precomputedRetirementPlan } : {}) }) + if (terminalCountBeforeClose <= 1 && state.activeWorktreeId === owningWorktreeId) { + // Why: re-read after the close — closeUnifiedTab may have already deactivated or + // repaired the surface, and the pre-close snapshot must not clobber that outcome. + const current = useAppStore.getState() + if (current.activeWorktreeId === owningWorktreeId) { + // Why: agent-session and simulator tabs render without a terminal/editor/browser + // entity, so only the unified renderable count can prove the worktree is empty + // (mirrors leaveWorktreeIfEmpty in useTabGroupTabCloseCommands). + const { renderableTabCount } = current.reconcileWorktreeTabModel(owningWorktreeId) + if (renderableTabCount === 0) { + const worktreeFile = current.openFiles.find((f) => f.worktreeId === owningWorktreeId) + if (worktreeFile) { + current.setActiveFile(worktreeFile.id) + current.setActiveTabType('editor') + } else { + const browserTab = (current.browserTabsByWorktree?.[owningWorktreeId] ?? [])[0] + if (browserTab) { + current.setActiveBrowserTab(browserTab.id) + current.setActiveTabType('browser') + } else { + current.setActiveWorktree(null) + } + } + } + } + } + retireStructuredSession() options?.onClosed?.() } diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts new file mode 100644 index 00000000000..cb6a65146ce --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + closeTab: vi.fn(), + closeFile: vi.fn(), + closeLocalTerminalTabState: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + closeStructuredTerminalSessionWithRetry: vi.fn(), + disposeStructuredTerminalSession: vi.fn(), + getState: vi.fn(), + isWebRuntimeSessionActive: vi.fn(), + reconcileTabOrder: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState } +})) + +vi.mock('@/lib/terminal-worktree-route', () => ({ + hasUnroutableTerminalWorktreeOwner: () => false, + resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: null }) +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab, + isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive +})) + +vi.mock('../tab-bar/reconcile-order', () => ({ + reconcileTabOrder: mocks.reconcileTabOrder +})) + +vi.mock('./close-local-terminal-tab-state', () => ({ + closeLocalTerminalTabState: mocks.closeLocalTerminalTabState +})) + +vi.mock('./structured-terminal-session-disposal', () => ({ + closeStructuredTerminalSessionWithRetry: mocks.closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession: mocks.disposeStructuredTerminalSession, + structuredTerminalSessionId: ( + tabs: { entityId: string; structuredSessionId?: string }[], + id: string + ) => tabs.find((tab) => tab.entityId === id)?.structuredSessionId ?? null +})) + +import { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' + +beforeEach(() => { + vi.clearAllMocks() + mocks.isWebRuntimeSessionActive.mockReturnValue(false) + mocks.reconcileTabOrder.mockReturnValue(['keep', 'close-a', 'close-b']) + mocks.closeStructuredTerminalSessionWithRetry.mockResolvedValue(true) +}) + +describe('adopted native-chat disposal in legacy terminal bulk actions', () => { + it('proves adopted-owner close before retiring other local terminals', async () => { + const state = { + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-adopted-1' + } + ] + }, + setActiveTab: vi.fn(), + closeTab: mocks.closeTab + } + mocks.getState.mockReturnValue(state) + + await closeOtherTerminalTabs('keep', 'wt-1') + + expect(mocks.closeTab).toHaveBeenCalledWith('close-a') + expect(mocks.closeStructuredTerminalSessionWithRetry).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-1' + ) + expect(mocks.disposeStructuredTerminalSession).not.toHaveBeenCalled() + }) + + it('proves adopted-owner close before retiring local terminals to the right', async () => { + const state = { + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-adopted-2' + } + ] + }, + openFiles: [], + tabBarOrderByWorktree: { 'wt-1': ['keep', 'close-a'] }, + closeTab: mocks.closeTab, + closeFile: mocks.closeFile + } + mocks.reconcileTabOrder.mockReturnValue(['keep', 'close-a']) + mocks.getState.mockReturnValue(state) + + await closeTerminalTabsToRight('keep', 'wt-1') + + expect(mocks.closeTab).toHaveBeenCalledWith('close-a') + expect(mocks.closeStructuredTerminalSessionWithRetry).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-2' + ) + expect(mocks.disposeStructuredTerminalSession).not.toHaveBeenCalled() + }) + + it('keeps a structured terminal visible when provider close is unproven', async () => { + mocks.closeStructuredTerminalSessionWithRetry.mockResolvedValue(false) + mocks.getState.mockReturnValue({ + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-live-1' + } + ] + }, + setActiveTab: vi.fn(), + closeTab: mocks.closeTab + }) + + await closeOtherTerminalTabs('keep', 'wt-1') + + expect(mocks.closeTab).not.toHaveBeenCalledWith('close-a') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts index 3bca59bd476..e4fc2254fcc 100644 --- a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts @@ -7,6 +7,11 @@ import { closeWebRuntimeSessionTab, isWebRuntimeSessionActive } from '@/runtime/ import { useAppStore } from '@/store' import { reconcileTabOrder } from '../tab-bar/reconcile-order' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' +import { + closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession, + structuredTerminalSessionId +} from './structured-terminal-session-disposal' const EDITOR_TAB_CONTENT_TYPES = new Set([ 'editor', @@ -29,7 +34,10 @@ function isPinnedVisibleTab( ) } -export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void { +export async function closeOtherTerminalTabs( + tabId: string, + activeWorktreeId: string | null +): Promise { if (!activeWorktreeId) { return } @@ -44,10 +52,23 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | activeWorktreeId )?.runtimeEnvironmentId const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) + const runtimeTarget = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) for (const tab of currentTabs) { if (tab.id === tabId || isPinnedVisibleTab(state, activeWorktreeId, tab.id)) { continue } + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[activeWorktreeId], + tab.id + ) + if ( + structuredSessionId && + !(await closeStructuredTerminalSessionWithRetry(runtimeTarget, structuredSessionId)) + ) { + continue + } if (closeHostTerminalTabs) { // Why: prune the mirror immediately, then close on its authoritative host so snapshots converge. closeLocalTerminalTabState(tab.id, { remoteCloseOwnedByHost: true }) @@ -57,13 +78,32 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | environmentId: runtimeEnvironmentId, reason: 'user' }) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: tab.id, + target: runtimeTarget, + reason: 'user' + }) + } } else { state.closeTab(tab.id) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: tab.id, + target: runtimeTarget, + reason: 'user' + }) + } } } } -export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string | null): void { +export async function closeTerminalTabsToRight( + tabId: string, + activeWorktreeId: string | null +): Promise { if (!activeWorktreeId) { return } @@ -79,6 +119,9 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string activeWorktreeId )?.runtimeEnvironmentId const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) + const runtimeTarget = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) const terminalIds = currentTerminalTabs.map((tab) => tab.id) const terminalIdSet = new Set(terminalIds) const orderedIds = reconcileTabOrder( @@ -96,6 +139,16 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string continue } if (terminalIdSet.has(id)) { + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[activeWorktreeId], + id + ) + if ( + structuredSessionId && + !(await closeStructuredTerminalSessionWithRetry(runtimeTarget, structuredSessionId)) + ) { + continue + } if (closeHostTerminalTabs) { // Why: prune the mirror immediately, then close on its authoritative host so snapshots converge. closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true }) @@ -105,8 +158,24 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string environmentId: runtimeEnvironmentId, reason: 'user' }) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: id, + target: runtimeTarget, + reason: 'user' + }) + } } else { state.closeTab(id) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: id, + target: runtimeTarget, + reason: 'user' + }) + } } continue } diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 480fca37fb8..ae9efcc798e 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -46,6 +46,7 @@ export type IpcEventsHarness = { useIpcEvents: () => void createTerminal: (request: CreateTerminalRequest) => void requestTerminalCreate: (request: RequestTerminalCreateRequest) => void + focusEditorTab: (request: { tabId: string; worktreeId: string }) => void replyTerminalCreate: ReturnType /** Fire a main-process digit chord (zero-based index). */ jumpToWorktreeIndex: (index: number) => void @@ -71,8 +72,7 @@ export type IpcEventsHarnessOptions = { } /** - * Loads useIpcEvents against a stubbed preload API and returns a driver for the - * create-terminal IPC, so reveal/adoption behavior is asserted through the hook. + * Loads useIpcEvents against a stubbed preload API so IPC behavior is asserted through the hook. */ export async function loadIpcEventsHarness( storeState: HarnessStoreState, @@ -82,6 +82,8 @@ export async function loadIpcEventsHarness( const activateAndRevealWorkspace = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null + let focusEditorTabListener: ((request: { tabId: string; worktreeId: string }) => void) | null = + null let navigationUpdateListener: | ((event: { browserPageId: string; url: string; title: string }) => void) | null = null @@ -155,6 +157,12 @@ export async function loadIpcEventsHarness( requestTerminalCreateListener = listener return () => {} }, + onFocusEditorTab: ( + listener: (request: { tabId: string; worktreeId: string }) => void + ) => { + focusEditorTabListener = listener + return () => {} + }, onJumpToWorktreeIndex: (listener: (index: number) => void) => { indexJumpListeners.set('worktree', listener) return () => {} @@ -244,6 +252,12 @@ export async function loadIpcEventsHarness( } requestTerminalCreateListener(request) }, + focusEditorTab: (request) => { + if (typeof focusEditorTabListener !== 'function') { + throw new Error('Expected the focus-editor-tab listener to be registered') + } + focusEditorTabListener(request) + }, replyTerminalCreate, jumpToWorktreeIndex: (index) => fireIndexJump(indexJumpListeners, 'worktree', index), jumpToTabIndex: (index) => fireIndexJump(indexJumpListeners, 'tab', index), diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts index 4a7269fe2e7..4bd590d1d9a 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -64,6 +64,11 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void const store = useAppStore.getState() const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + // Why: chat-completion focus is a courtesy reveal, not navigation — never yank the user + // back into a workspace they deliberately left. + if (tab?.contentType === 'agent-session' && store.activeWorktreeId !== worktreeId) { + return + } if (!tab) { if (browserTarget) { // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. @@ -81,7 +86,9 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void store.setActiveView('terminal') store.focusGroup(worktreeId, tab.groupId) store.activateTab(tab.id) - if (browserTarget) { + if (tab.contentType === 'agent-session') { + store.setActiveTabType('agent-session') + } else if (browserTarget) { // Why: browser tabs need their own active-page state, not the editor file activation path. store.setActiveBrowserTab(browserTarget.workspaceId) store.setActiveTabType('browser') diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 8237998e62c..9a2a760dad5 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -79,6 +79,11 @@ export function activateCyclableTab(store: AppStoreState, next: TypeCyclableTab) store.activateTab?.(next.tabId) } store.setActiveTabType('simulator') + } else if (next.type === 'agent-session') { + if (next.tabId) { + store.activateTab?.(next.tabId) + } + store.setActiveTabType('agent-session') } else { // Why: `setActiveFile` targets the file entity (its implicit activateTab // picks the first matching tab in the active group); `activateTab(tabId)` diff --git a/src/renderer/src/hooks/modal-return-focus-action.ts b/src/renderer/src/hooks/modal-return-focus-action.ts index 4e516001bfc..5a4eef905ee 100644 --- a/src/renderer/src/hooks/modal-return-focus-action.ts +++ b/src/renderer/src/hooks/modal-return-focus-action.ts @@ -1,10 +1,11 @@ import type { BrowserFocusTarget } from '../components/browser-pane/host-guest/browser-focus' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' // The surface that held focus before a modal (QuickOpen, Cmd+J, ...) opened. // Captured at open time because Radix steals document focus once the dialog // mounts, so the raw activeElement is gone by close time. export type ModalReturnFocusSurface = { - tabType: 'browser' | 'editor' | 'terminal' | 'simulator' + tabType: WorkspaceVisibleTabType worktreeId: string | null browserPageId: string | null browserTarget: BrowserFocusTarget diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index cc727f4c8a7..5aeb0148c70 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -4,7 +4,7 @@ */ export function resolveZoomTarget(args: { activeView: TopLevelView - activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' + activeTabType: WorkspaceVisibleTabType activeElement: unknown }): 'terminal' | 'editor' | 'simulator' | 'ui' { const { activeView, activeTabType, activeElement } = args @@ -55,3 +55,4 @@ export function resolveZoomTarget(args: { return 'ui' } import type { TopLevelView } from '../../../shared/ui-chrome-types' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' diff --git a/src/renderer/src/hooks/structured-session-completion-focus.test.ts b/src/renderer/src/hooks/structured-session-completion-focus.test.ts new file mode 100644 index 00000000000..dfadee2c874 --- /dev/null +++ b/src/renderer/src/hooks/structured-session-completion-focus.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createHarnessStoreState, + loadIpcEventsHarness, + type HarnessStoreState +} from './ipc-events-test-harness' + +const SESSION_WORKSPACE_ID = 'repo-1::/session-workspace' +const OTHER_WORKSPACE_ID = 'repo-1::/other-workspace' +const TAB_ID = 'structured-agent-session-session-1' + +function createStoreState(activeWorktreeId: string): HarnessStoreState { + return createHarnessStoreState({ + tabsByWorktree: {}, + activeWorktreeId, + unifiedTabsByWorktree: { + [SESSION_WORKSPACE_ID]: [ + { + id: TAB_ID, + entityId: 'session-1', + groupId: 'group-1', + worktreeId: SESSION_WORKSPACE_ID, + contentType: 'agent-session' + } + ] + }, + focusGroup: vi.fn(), + activateTab: vi.fn() + }) +} + +describe('structured session completion focus', () => { + it('focuses the chat tab when its workspace is active', async () => { + const store = createStoreState(SESSION_WORKSPACE_ID) + const harness = await loadIpcEventsHarness(store) + harness.useIpcEvents() + + harness.focusEditorTab({ tabId: TAB_ID, worktreeId: SESSION_WORKSPACE_ID }) + + expect(store.focusGroup).toHaveBeenCalledWith(SESSION_WORKSPACE_ID, 'group-1') + expect(store.activateTab).toHaveBeenCalledWith(TAB_ID) + expect(store.setActiveTabType).toHaveBeenCalledWith('agent-session') + }) + + it('does not apply focus after the user moves to another workspace', async () => { + const store = createStoreState(OTHER_WORKSPACE_ID) + const harness = await loadIpcEventsHarness(store) + harness.useIpcEvents() + + harness.focusEditorTab({ tabId: TAB_ID, worktreeId: SESSION_WORKSPACE_ID }) + + expect(store.setActiveWorktree).not.toHaveBeenCalled() + expect(store.markWorktreeVisited).not.toHaveBeenCalled() + expect(store.setActiveView).not.toHaveBeenCalled() + expect(store.focusGroup).not.toHaveBeenCalled() + expect(store.activateTab).not.toHaveBeenCalled() + expect(store.setActiveTabType).not.toHaveBeenCalled() + expect(store.revealWorktreeInSidebar).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/i18n/locale-english-regression.test.ts b/src/renderer/src/i18n/locale-english-regression.test.ts index dc20755de6e..8f120f1d23c 100644 --- a/src/renderer/src/i18n/locale-english-regression.test.ts +++ b/src/renderer/src/i18n/locale-english-regression.test.ts @@ -22,6 +22,23 @@ import zh from './locales/zh.json' const catalogs = { es, ja, ko, zh } +const WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS = { + zh: { + selectionWithheldOne: '当前筛选条件隐藏了 1 个已选工作区,已取消选择。', + selectionWithheld: '当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。', + selectAllCountOne: '选择 1 个通过安全检查的工作区', + selectAllCount: '选择全部 {{value0}} 个通过安全检查的工作区' + }, + ko: { + selectionWithheldOne: + '선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', + selectionWithheld: + '선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', + selectAllCountOne: '안전 검사를 통과한 워크스페이스 1개 선택', + selectAllCount: '안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택' + } +} as const + function lookup(catalog: unknown, key: string): string | undefined { const value = key .split('.') @@ -67,4 +84,14 @@ describe('locale catalogs reverted by a stale branch base (#10770)', () => { 'Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.' ) }) + + it.each(Object.entries(WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS))( + '%s retains the merged workspace cleanup browse translations', + (code, translations) => { + const catalog = code === 'zh' ? zh : ko + for (const [key, expected] of Object.entries(translations)) { + expect(lookup(catalog, `components.workspace.cleanup.browse.${key}`)).toBe(expected) + } + } + ) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 123f2f6035c..c8f8d7bdf11 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -932,6 +932,9 @@ } } }, + "activateAiVaultStructuredSession": { + "unavailable": "The structured agent session is not available yet. Retry in a moment." + }, "ephemeralVmWorktreeCreation": { "sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout." }, @@ -6888,13 +6891,17 @@ "nativeChat": { "title": "Chat UI", "description": "Preview the desktop chat surface for supported agent terminal sessions.", - "copy": "Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", + "copy": "Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.", "toggleLabel": "Toggle Chat UI", "defaultTitle": "Default view", "defaultCopy": "Choose how new supported agent terminal tabs open.", "defaultViewLabel": "Default Chat UI view", "defaultViewTerminal": "Terminal chat", - "defaultViewNative": "Chat UI" + "defaultViewNative": "Chat UI", + "structuredTitle": "Use updated structured native chat", + "structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.", + "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.", + "structuredToggleLabel": "Toggle updated structured native chat" }, "agentDashboard": { "title": "Agent Dashboard", @@ -16373,6 +16380,15 @@ "clientNoteLocalStorage": "Imports read this device’s browsers. Cookies are stored locally.", "remoteNoteRemoteStorage": "Imports read browsers on {{value0}}. Cookies are stored on that machine, and a permission prompt may appear on its screen." }, + "native": { + "chat": { + "NativeChatStructuredSession": { + "1f772bb5d0": "Message delivery is unconfirmed.", + "93ef441197": "Message was not sent.", + "a5e7f14068": "Retry" + } + } + }, "ComposerParentWorktreePicker": { "label": "Parent worktree", "noParent": "No parent", @@ -16519,6 +16535,9 @@ "countOne": "1 tool call", "countN": "{{value0}} tool calls" }, + "providerFrame": { + "byteLength": "{{value0}} bytes" + }, "status": { "responding": "Agent is responding" }, @@ -16569,7 +16588,15 @@ "allow": "Allow", "deny": "Deny" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "Not delivered — check the terminal", + "orchestrationPaused": { + "label": "Orchestration paused", + "message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with", + "command": "orca orchestration check" + }, + "structuredSessionCloseFailed": "Could not close this Codex chat", + "structuredSessionLaunchFailed": "Could not open Codex chat", + "structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable." }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3f75e38e999..fa616915282 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14678,29 +14678,16 @@ }, "workspace": { "cleanup": { - "browse": { - "selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", - "selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", - "selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택", - "selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택" - }, - "scan": { - "readyOne": "워크스페이스 1개를 찾았습니다.", - "readyMany": "워크스페이스 {{value0}}개를 찾았습니다." - }, - "presentationFixtures": { - "reviewAlphaCleanup": "알파 정리 검토" - }, - "presentation": { - "gitlabMergeRequestNumber": "MR #{{value0}}", - "githubPullRequestNumber": "PR #{{value0}}" - }, "browse": { "gitStatusCheckFailed": "Git 상태 확인 실패", "gitStatusUnverified": "Git 상태를 확인할 수 없음", "deleteAnyway": "그래도 삭제", "forceDeleteProjectionOne": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", "forceDeleteProjectionMany": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", + "selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", + "selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", + "selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택", + "selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택", "appliedFilters": "적용된 필터", "removeFilter": "{{value0}} 필터 제거", "chip": { @@ -14747,6 +14734,17 @@ "retainedAgents": "완료된 에이전트" } } + }, + "scan": { + "readyOne": "워크스페이스 1개를 찾았습니다.", + "readyMany": "워크스페이스 {{value0}}개를 찾았습니다." + }, + "presentationFixtures": { + "reviewAlphaCleanup": "알파 정리 검토" + }, + "presentation": { + "gitlabMergeRequestNumber": "MR #{{value0}}", + "githubPullRequestNumber": "PR #{{value0}}" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d4304abf23..3f2a1e2d1ed 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14702,29 +14702,16 @@ }, "workspace": { "cleanup": { - "browse": { - "selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。", - "selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。", - "selectAllCountOne": "选择 1 个通过安全检查的工作区", - "selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区" - }, - "scan": { - "readyOne": "找到 1 个工作区。", - "readyMany": "找到 {{value0}} 个工作区。" - }, - "presentationFixtures": { - "reviewAlphaCleanup": "评审 Alpha 清理" - }, - "presentation": { - "gitlabMergeRequestNumber": "MR #{{value0}}", - "githubPullRequestNumber": "PR #{{value0}}" - }, "browse": { "gitStatusCheckFailed": "Git 状态检查失败", "gitStatusUnverified": "无法验证 Git 状态", "deleteAnyway": "仍然删除", "forceDeleteProjectionOne": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", "forceDeleteProjectionMany": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", + "selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。", + "selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。", + "selectAllCountOne": "选择 1 个通过安全检查的工作区", + "selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区", "appliedFilters": "已应用的筛选", "removeFilter": "移除筛选 {{value0}}", "chip": { @@ -14771,6 +14758,17 @@ "retainedAgents": "已完成的代理" } } + }, + "scan": { + "readyOne": "找到 1 个工作区。", + "readyMany": "找到 {{value0}} 个工作区。" + }, + "presentationFixtures": { + "reviewAlphaCleanup": "评审 Alpha 清理" + }, + "presentation": { + "gitlabMergeRequestNumber": "MR #{{value0}}", + "githubPullRequestNumber": "PR #{{value0}}" } } }, diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts new file mode 100644 index 00000000000..1e6cd961d98 --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { activateAiVaultStructuredSession } from './activate-ai-vault-structured-session' + +const structuredSession = { + structuredSession: { sessionId: 'session-1', workspaceId: 'workspace-1' } +} as AiVaultSession + +describe('activateAiVaultStructuredSession', () => { + it('refreshes an unpublished structured tab before activating it', async () => { + const activate = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const refresh = vi.fn(async () => undefined) + const unavailable = vi.fn() + + await expect( + activateAiVaultStructuredSession(structuredSession, { activate, refresh, unavailable }) + ).resolves.toBe(true) + + expect(refresh).toHaveBeenCalledWith('workspace-1') + expect(activate).toHaveBeenCalledTimes(2) + expect(unavailable).not.toHaveBeenCalled() + }) + + it('surfaces a retryable state when the structured tab remains unavailable', async () => { + const activate = vi.fn(() => false) + const refresh = vi.fn(async () => undefined) + const unavailable = vi.fn() + + await expect( + activateAiVaultStructuredSession(structuredSession, { activate, refresh, unavailable }) + ).resolves.toBe(true) + + expect(activate).toHaveBeenCalledTimes(2) + expect(unavailable).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts new file mode 100644 index 00000000000..5fc0684458b --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -0,0 +1,95 @@ +import { toast } from 'sonner' +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { activateAndRevealWorktree } from './worktree-activation' +import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation' +import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync' + +const STRUCTURED_SESSION_RESTORE_TIMEOUT_MS = 5_000 + +type StructuredSessionActivationDeps = { + activate: typeof activateStructuredAgentSessionById + refresh: (worktreeId: string) => Promise + unavailable: () => void +} + +const defaultDeps: StructuredSessionActivationDeps = { + activate: activateStructuredAgentSessionById, + refresh: refreshStructuredSessionTabs, + unavailable: () => { + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) + } +} + +export async function activateAiVaultStructuredSession( + session: AiVaultSession, + deps: StructuredSessionActivationDeps = defaultDeps +): Promise { + const structured = session.structuredSession + if (!structured) { + return false + } + const target = { worktreeId: structured.workspaceId, sessionId: structured.sessionId } + if (!deps.activate(target)) { + try { + await deps.refresh(structured.workspaceId) + } catch { + deps.unavailable() + return true + } + if (!deps.activate(target)) { + deps.unavailable() + return true + } + } + if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { + activateAndRevealWorktree(structured.workspaceId) + } + return true +} + +async function refreshStructuredSessionTabs(worktreeId: string): Promise { + const state = useAppStore.getState() + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const snapshot = await withStructuredSessionRestoreTimeout( + callRuntimeRpc( + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + 'session.tabs.list', + { worktree: toRuntimeWorktreeSelector(worktreeId) }, + { timeoutMs: STRUCTURED_SESSION_RESTORE_TIMEOUT_MS } + ) + ) + applyStructuredSessionTabSnapshots( + [snapshot], + environmentId ? `structured-session:${environmentId}` : undefined + ) +} + +async function withStructuredSessionRestoreTimeout(promise: Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('structured_session_restore_timeout')), + STRUCTURED_SESSION_RESTORE_TIMEOUT_MS + ) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 4d93a29dbe3..6968a21ae1c 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -218,6 +218,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') }) + it('marks a seeded launch prompt failed when paste delivery rejects', async () => { + const error = new Error('prompt transport rejected') + mocks.pasteDraftWhenAgentReady.mockRejectedValue(error) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + ).rejects.toBe(error) + + expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + }) + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { // claude delivers via `--prefill` at launch, so paste no-ops (returns false) // when forcePaste is false — that is a native delivery, not a failure. diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 87680462cce..864c25f3e32 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -64,10 +64,18 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout - }).then((delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { - useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + }).then( + (delivered) => { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + return delivered || deliversViaNativePrefill + }, + (error) => { + if (shouldSeed && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + throw error } - return delivered || deliversViaNativePrefill - }) + ) } diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 69c17c5caa6..536616fdf81 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -11,6 +11,7 @@ export const AI_VAULT_SESSION_DRAG_PAYLOAD_MAX_BYTES = 16 * 1024 export type AiVaultSessionDragPayload = { agent: AiVaultAgent sessionId: string + structuredSession?: { sessionId: string; workspaceId: string } title: string command: string // Why: drop targets must know where the session file lives (host vs local @@ -49,6 +50,16 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0 } +function isStructuredSession( + value: unknown +): value is NonNullable { + if (!value || typeof value !== 'object') { + return false + } + const structured = value as Record + return isNonEmptyString(structured.sessionId) && isNonEmptyString(structured.workspaceId) +} + function isStringRecord(value: unknown): value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -87,6 +98,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD payload.version === 1 && isAiVaultAgent(payload.agent) && isNonEmptyString(payload.sessionId) && + (payload.structuredSession === undefined || isStructuredSession(payload.structuredSession)) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && @@ -165,6 +177,7 @@ export function readAiVaultSessionDragData( const { agent, sessionId, + structuredSession, title, command, sessionFilePath, @@ -179,6 +192,7 @@ export function readAiVaultSessionDragData( return { agent, sessionId, + ...(structuredSession ? { structuredSession } : {}), title, command, ...(sessionFilePath ? { sessionFilePath } : {}), diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index 2ff2d5be6ab..1a26b8b80b7 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -19,6 +19,7 @@ describe('prepareAiVaultSessionForResume', () => { expect(prepared.codexHome).toBeNull() expect(prepareSessionResume).toHaveBeenCalledWith({ agent: 'codex', + sessionId: legacy.sessionId, filePath: legacy.filePath, codexHome: legacy.codexHome, executionHostId: 'local' @@ -55,6 +56,7 @@ describe('prepareAiVaultSessionForResume', () => { expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home') expect(prepareSessionResume).toHaveBeenCalledWith({ agent: 'codex', + sessionId: current.sessionId, filePath: current.filePath, codexHome: current.codexHome, executionHostId: 'local' diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index fc78dbed0c1..9c7fa8740ff 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -8,11 +8,12 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' export async function prepareAiVaultSessionForResume( session: AiVaultSession ): Promise { - if (!aiVaultSessionNeedsResumePreparation(session)) { + if (!session.structuredSession && !aiVaultSessionNeedsResumePreparation(session)) { return session } const result = await window.api.aiVault.prepareSessionResume({ agent: session.agent, + sessionId: session.sessionId, filePath: session.filePath, codexHome: session.codexHome, executionHostId: session.executionHostId diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index a52c743e7b8..93b5d71f442 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' -import type { Tab } from '../../../shared/tab-types' +import type { Tab, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import type { ExecutionHostId } from '../../../shared/execution-host' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' @@ -14,7 +14,7 @@ import { isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' -type BrowserPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type BrowserPaletteActiveTabType = WorkspaceVisibleTabType export type BuildSearchableBrowserPagesOptions = { worktrees: readonly Worktree[] diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 5f9ce256885..979823f7f4b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -5,6 +5,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() const mockSetActiveTabType = vi.fn() +const mockSetTabViewMode = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() @@ -13,6 +14,7 @@ const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() +const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -31,6 +33,7 @@ const store = { activeRuntimeEnvironmentId: string | null terminalWindowsShell?: string experimentalNativeChat?: boolean + experimentalStructuredNativeChat?: boolean openAgentTabsInChatByDefault?: boolean nativeChatSessionOptions?: Record< string, @@ -79,6 +82,7 @@ const store = { closeTab: vi.fn(), queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, + setTabViewMode: mockSetTabViewMode, setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, @@ -112,6 +116,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-ready-wait', () => ({ + waitForAgentReady: mockWaitForAgentReady +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -171,6 +179,7 @@ describe('launchAgentInNewTab', () => { store.ptyIdsByTabId = {} mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) }) it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { @@ -185,7 +194,6 @@ describe('launchAgentInNewTab', () => { launchAgent: 'codex' }) }) - it('keeps Floating Workspace authority on native Windows beside an active WSL project', async () => { store.projects = [ { @@ -212,13 +220,14 @@ describe('launchAgentInNewTab', () => { ) }) - it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + it('keeps prompted Codex launches on the ordinary terminal path', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -246,6 +255,7 @@ describe('launchAgentInNewTab', () => { text: 'large generated prompt', createdAt: expect.any(Number) }) + expect(mockSetTabViewMode).not.toHaveBeenCalled() }) it('opens local Grok submit-after-ready launches in native chat', async () => { @@ -255,6 +265,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -286,6 +297,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] @@ -306,6 +318,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -317,8 +330,7 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'draft' }) - // Claude takes the draft on --prefill, so no paste runs and - // deliverLaunchPromptToAgentTab never fires — this is the only seed. + // Claude's --prefill launch seeds the draft without a paste callback. expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ @@ -327,12 +339,7 @@ describe('launchAgentInNewTab', () => { text: 'https://github.com/o/r/issues/12' }) ) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) + expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') }) it('mirrors a multi-line draft into chat and opens the tab there', async () => { @@ -342,6 +349,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -357,12 +365,7 @@ describe('launchAgentInNewTab', () => { expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) ) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) + expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') }) it('passes quick command labels only to locally-created agent tabs', async () => { @@ -387,6 +390,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: false, nativeChatSessionOptions: { codex: { @@ -419,6 +423,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { codex: { @@ -447,6 +452,7 @@ describe('launchAgentInNewTab', () => { undefined, expect.objectContaining({ viewMode: 'chat' }) ) + expect(mockSetTabViewMode).not.toHaveBeenCalled() }) it('preserves paired-host draft delivery and supported launch preferences', async () => { @@ -457,6 +463,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { claude: { @@ -476,8 +483,6 @@ describe('launchAgentInNewTab', () => { }) expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) - // The draft rides in on the launch command, so this host-class launch also - // carries the text that seeds the mirrored tab's chat composer. expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ launchAgent: 'claude', @@ -501,6 +506,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -526,6 +532,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: false } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -599,8 +606,6 @@ describe('launchAgentInNewTab', () => { prompt: 'fix the spinner', launchSource: 'onboarding' }) - - expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) it('does not track prompt-sent for draft launches', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d46cb530bd6..b6cbbb736d8 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -29,6 +29,8 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' +import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -56,9 +58,17 @@ export type LaunchAgentInNewTabResult = { tabId: string | null startupPlan: AgentStartupPlan pasteDraftAfterLaunch: boolean + /** The host will publish and focus a structured tab asynchronously. */ + focusAfterMenuClose?: 'structured-session' promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> } | null +export function shouldQueueTerminalFocusAfterMenuClose( + result: NonNullable +): boolean { + return result.tabId === null && result.focusAfterMenuClose !== 'structured-session' +} + /** * Create a new terminal tab and queue the agent's launch command, optionally * with an initial prompt. @@ -173,6 +183,21 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } } + const launchDirectStructuredChat = + agent === 'codex' && + !hasPrompt && + store.settings?.experimentalNativeChat === true && + canUseStructuredNativeChat(store, worktreeId) + if (launchDirectStructuredChat) { + startStructuredCodexLaunch(worktreeId) + return { + tabId: null, + startupPlan, + pasteDraftAfterLaunch: false, + focusAfterMenuClose: 'structured-session' + } + } + // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts new file mode 100644 index 00000000000..c9d63359267 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockSetTabViewMode = vi.fn() +const mockWaitForAgentReady = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() +const mockCreateStructuredCodexSessionLaunchIntent = vi.fn() +const mockLaunchStructuredCodexSession = vi.fn() +const mockRefreshLocalStructuredSessionTabs = vi.fn() +const mockToastError = vi.fn() + +function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') { + return { + sessionId, + worktreeId, + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${worktreeId}`, + agent: 'codex' as const + } + } +} + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {}, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }] + }, + detectedWorktreesByRepo: {}, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: vi.fn(), + setActiveTabType: vi.fn(), + setTabViewMode: mockSetTabViewMode, + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: mockToastError } })) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) +vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) +vi.mock('@/lib/launch-structured-codex-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredCodexSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession: mockLaunchStructuredCodexSession, + StructuredAgentSessionCreateRefusalError + } +}) +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs, + LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session' +})) + +/** Structured adoption creates the tab in terminal mode and flips it to chat once + * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That + * difference is the only observable signal that the availability guard ran. */ +describe('structured chat adoption guard on the launch path', () => { + beforeEach(() => { + vi.clearAllMocks() + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) => + structuredLaunchIntent(worktreeId) + ) + mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + mockRefreshLocalStructuredSessionTabs.mockResolvedValue([ + { + worktree: 'wt-1', + tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] + } + ]) + mockToastError.mockReset() + store.settings.openAgentTabsInChatByDefault = true + }) + + it('takes the structured path when the chat-default view is selected', async () => { + const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } = + await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result).toMatchObject({ + tabId: null, + pasteDraftAfterLaunch: false, + focusAfterMenuClose: 'structured-session' + }) + expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false) + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1') + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-1' }) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) + + /** The toggle is hidden under Terminal chat but its persisted value survives, so the launch + * path must re-check the default view rather than trust a stale opt-in. */ + it('ignores a stale structured opt-in while the default view is Terminal chat', async () => { + store.settings.openAgentTabsInChatByDefault = false + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result?.tabId).toBe('tab-1') + expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + }) + + it('surfaces a direct structured launch failure instead of silently doing nothing', async () => { + const { StructuredAgentSessionCreateRefusalError } = + await import('./launch-structured-codex-session') + mockLaunchStructuredCodexSession.mockRejectedValueOnce( + new StructuredAgentSessionCreateRefusalError('provider unavailable') + ) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false }) + await vi.waitFor(() => + expect(mockToastError).toHaveBeenCalledWith( + 'Could not open Codex chat', + expect.objectContaining({ description: 'provider unavailable' }) + ) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + }) + + it('coalesces repeated structured launches for one worktree while the host is starting', async () => { + let resolveLaunch!: (sessionId: string) => void + mockLaunchStructuredCodexSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveLaunch = resolve)) + ) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const first = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + const second = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(first).toMatchObject({ focusAfterMenuClose: 'structured-session' }) + expect(second).toMatchObject({ focusAfterMenuClose: 'structured-session' }) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + resolveLaunch('codex-session-1') + }) + + it('keeps the single-flight reservation until the published tab inventory is refreshed', async () => { + let resolveRefresh!: (snapshots: unknown[]) => void + mockRefreshLocalStructuredSessionTabs.mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)) + ) + mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1)) + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + resolveRefresh([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } + ]) + await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled()) + }) + + it('does not create a sibling when post-create visibility proof is unknown', async () => { + const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1') + const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2') + mockCreateStructuredCodexSessionLaunchIntent + .mockReturnValueOnce(firstIntent) + .mockReturnValueOnce(secondIntent) + mockLaunchStructuredCodexSession + .mockResolvedValueOnce(firstIntent.sessionId) + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce(secondIntent.sessionId) + mockRefreshLocalStructuredSessionTabs + .mockRejectedValueOnce(new Error('inventory unavailable')) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } + ]) + .mockResolvedValueOnce([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] } + ]) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1)) + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3)) + + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[0]).toBe(firstIntent) + expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[0]).toBe(firstIntent) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // A successful retry must release the reservation so a later launch can start normally. + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4)) + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3) + expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent) + }) + + it('keeps prompted Codex on the ordinary terminal launch path', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'start this task' + }) + + expect(result?.tabId).toBe('tab-1') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + expect(mockSetTabViewMode).not.toHaveBeenCalled() + }) + + it('shows rejected prompt delivery in chat after Codex becomes ready', async () => { + const error = new Error('prompt transport rejected') + mockPasteDraftWhenAgentReady.mockRejectedValue(error) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).rejects.toBe(error) + expect(mockMarkNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + expect(mockSetTabViewMode).not.toHaveBeenCalled() + }) + + it('keeps an SSH Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) + + it('keeps a runtime-paired Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.test.ts b/src/renderer/src/lib/launch-structured-codex-session.test.ts new file mode 100644 index 00000000000..94bc0b04b0e --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + createStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession +} from './launch-structured-codex-session' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +describe('structured Codex launch', () => { + beforeEach(() => { + vi.mocked(callStructuredAgentSession).mockReset() + }) + + it('creates a native session with a host-verifiable launch intent', async () => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, + fence: 1, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + })) + + const intent = createStructuredCodexSessionLaunchIntent('workspace-1') + const sessionId = await launchStructuredCodexSession(intent) + const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: 'codex' + } + + expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/) + expect(callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.create', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { worktree: 'id:workspace-1', agent: 'codex' } + }) + ) + expect(params).toBe(intent.params) + }) + + it('replays the exact create envelope when an unknown outcome is retried', async () => { + const intent = createStructuredCodexSessionLaunchIntent('workspace-retry') + vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost')) + + await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost') + await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost') + + const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] + const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2] + expect(first).toBe(intent.params) + expect(second).toBe(first) + expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-codex-session.ts new file mode 100644 index 00000000000..b4deb731c28 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.ts @@ -0,0 +1,87 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { useAppStore } from '@/store' +import { + clearWebSessionFocusIntentIfMatches, + recordWebSessionFocusIntent, + resolveWebSessionVisibleTabId +} from '@/runtime/web-session-focus-intent' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync' + +type StructuredAgentSessionCreateParams = { + envelope: AgentSessionMutationEnvelope + worktree: string + agent: 'codex' +} + +export type StructuredAgentSessionLaunchIntent = { + sessionId: string + worktreeId: string + params: StructuredAgentSessionCreateParams +} + +export class StructuredAgentSessionCreateRefusalError extends Error {} + +export function createStructuredCodexSessionLaunchIntent( + worktreeId: string +): StructuredAgentSessionLaunchIntent { + const sessionId = `codex_${crypto.randomUUID().replaceAll('-', '_')}` + const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const } + const state = useAppStore.getState() + recordWebSessionFocusIntent( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(state, worktreeId) + ) + return { + sessionId, + worktreeId, + params: { + envelope: { + sessionId, + clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId, + fields + }) + }, + ...fields + } + } +} + +export function abandonStructuredAgentSessionLaunchIntent( + intent: StructuredAgentSessionLaunchIntent +): void { + clearWebSessionFocusIntentIfMatches( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + intent.worktreeId, + `agent-session:${intent.sessionId}` + ) +} + +export async function launchStructuredCodexSession( + intent: StructuredAgentSessionLaunchIntent +): Promise { + const result = await callStructuredAgentSession< + AgentSessionMutationResult + >({ kind: 'local' }, 'agentSession.create', intent.params) + if (!result.ok) { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError(result.refusal.message) + } + return result.value.sessionId +} diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts index 195fa721812..9b8cb76519c 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts @@ -34,6 +34,48 @@ function makeTerminalTab(id: string): Record { } describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { + it('publishes the resume command and ownership claim with the first visible tab state', () => { + const record = makeRecord() + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + let firstVisible: + | { + command: string | undefined + claim: unknown + layout: ReturnType['terminalLayoutsByTabId'][string] + } + | undefined + const unsubscribe = useAppStore.subscribe((state) => { + const tab = state.tabsByWorktree['wt-1']?.[0] + if (tab && !firstVisible) { + firstVisible = { + command: state.pendingStartupByTabId[tab.id]?.command, + claim: state.automaticAgentResumeClaimsByTabId[tab.id], + layout: state.terminalLayoutsByTabId[tab.id] + } + } + }) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + unsubscribe() + + expect(firstVisible?.command).toContain('resume') + expect(firstVisible?.command).toContain(record.providerSession.id) + expect(firstVisible?.claim).toEqual({ + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }) + expect(firstVisible?.layout).toMatchObject({ + root: { type: 'leaf', leafId: expect.any(String) }, + activeLeafId: expect.any(String) + }) + const root = firstVisible?.layout?.root + expect(firstVisible?.layout?.activeLeafId).toBe(root?.type === 'leaf' ? root.leafId : undefined) + }) + it('stores provider-session metadata in the queued startup and runtime claim', () => { const record = makeRecord() useAppStore.setState({ diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 1865dc747af..94dc52bc7c6 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -13,6 +13,7 @@ import { launchSleepingAgentSession, type ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness' import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration' @@ -136,6 +137,9 @@ function activeOrQueuedResumeClaimsProviderSession( // Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the // prompt — so discarding those records only stranded the session across wake and restart. function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { + if (isStructuredAgentSyntheticSleepingRecord(record)) { + return true + } if (!record.origin && record.state === 'done') { return true } diff --git a/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts new file mode 100644 index 00000000000..db16786d27a --- /dev/null +++ b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const WORKTREE_ID = 'stale-structured-worktree' +const SESSION_ID = 'structured-session-stale' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +describe('stale structured sleeping session', () => { + it('clears the synthetic terminal projection without spawning', () => { + const tabId = structuredAgentSessionTabId(SESSION_ID) + const paneKey = makePaneKey(tabId, LEAF_ID) + const record: SleepingAgentSessionRecord = { + paneKey, + tabId, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(useAppStore.getState().pendingStartupByTabId).toEqual({}) + }) +}) diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 83d1b376f74..ea0e0aa2c2f 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { ExecutionHostId } from '../../../shared/execution-host' -import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' @@ -62,7 +62,7 @@ export type SimulatorPaletteSearchResult = { lastActiveAt?: number | null } -type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type SimulatorPaletteActiveTabType = WorkspaceVisibleTabType export const SIMULATOR_PALETTE_QUERY_MAX_BYTES = 2 * 1024 diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 9fe908050a0..59d54fbafb9 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' const mockCreateTab = vi.fn() -const mockQueueTabStartupCommand = vi.fn() const store = { settings: { @@ -47,7 +46,6 @@ const store = { browserTabsByWorktree: {} as Record, tabBarOrderByWorktree: {} as Record, createTab: mockCreateTab, - queueTabStartupCommand: mockQueueTabStartupCommand, claimAutomaticAgentResume: vi.fn(), clearSleepingAgentSession: vi.fn(), setActiveTabType: vi.fn(), @@ -83,10 +81,10 @@ const record: SleepingAgentSessionRecord = { async function launch(): Promise { const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch') launchSleepingAgentSession(record) - const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as - | { command: string } + const options = mockCreateTab.mock.calls.at(-1)?.[3] as + | { pendingStartup?: { command: string } } | undefined - return queued?.command + return options?.pendingStartup?.command } describe('launchSleepingAgentSession Windows shell quoting', () => { diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 6d5f0d5ded2..43d7bdb3b30 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -100,30 +100,30 @@ export function launchSleepingAgentSession( const tab = state.createTab(record.worktreeId, undefined, undefined, { launchAgent: record.agent, + pendingStartup: { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + resumeProviderSession: record.providerSession, + launchAgent: record.agent, + ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + showSessionRestoredBanner: true, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }, + automaticResumeClaim: { + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }, ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) - state.queueTabStartupCommand(tab.id, { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - resumeProviderSession: record.providerSession, - launchAgent: record.agent, - ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - showSessionRestoredBanner: true, - telemetry: { - agent_kind: tuiAgentToAgentKind(record.agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - }) - state.claimAutomaticAgentResume(tab.id, { - worktreeId: record.worktreeId, - launchAgent: record.agent, - providerSession: record.providerSession - }) state.clearSleepingAgentSession(record.paneKey) if (!options?.suppressNavigation) { state.setActiveTabType('terminal') diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..d345171cf0d --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' + +const mocks = vi.hoisted(() => ({ + createIntent: vi.fn(), + launch: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-structured-codex-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredCodexSessionLaunchIntent: mocks.createIntent, + launchStructuredCodexSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { + StructuredAgentSessionCreateRefusalError, + type StructuredAgentSessionLaunchIntent +} from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { startStructuredCodexLaunch } from './structured-agent-session-launch' + +function launchIntent( + worktreeId: string, + sessionId = `session-${worktreeId}` +): StructuredAgentSessionLaunchIntent { + return { + worktreeId, + sessionId, + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${sessionId}` + }, + worktree: `id:${worktreeId}`, + agent: 'codex' + } + } +} + +function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} + +async function flushLaunchSettlement(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +describe('startStructuredCodexLaunch', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createIntent.mockImplementation((worktreeId: string) => launchIntent(worktreeId)) + }) + + it('opens the chat without an informational progress toast', async () => { + const worktreeId = 'wt-open-quiet' + const intent = launchIntent(worktreeId, 'session-1') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue(intent.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.launch).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledWith(intent) + expect(toast.message).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('coalesces a duplicate click silently while the launch is in flight', async () => { + const worktreeId = 'wt-duplicate-click' + const intent = launchIntent(worktreeId) + let resolveLaunch: (sessionId: string) => void = () => {} + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementation( + () => new Promise((resolve) => (resolveLaunch = resolve)) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + startStructuredCodexLaunch(worktreeId) + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + resolveLaunch(intent.sessionId) + await flushLaunchSettlement() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reconciles a host commit when the create reply is lost', async () => { + const worktreeId = 'wt-response-loss' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValueOnce(new Error('response lost')) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('retries an absent unknown outcome with the exact same intent', async () => { + const worktreeId = 'wt-same-envelope-retry' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce(intent.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([publishedSnapshot(worktreeId, intent.sessionId)]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.launch).toHaveBeenCalledTimes(2) + expect(mocks.launch.mock.calls[0]?.[0]).toBe(intent) + expect(mocks.launch.mock.calls[1]?.[0]).toBe(intent) + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('keeps an unresolved identity reserved until inventory reconciles it', async () => { + const worktreeId = 'wt-still-unknown' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValue(new Error('offline')) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + expect(toast.error).toHaveBeenCalledOnce() + + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledTimes(2) + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('releases a definitively refused intent so a new click can create a new identity', async () => { + const worktreeId = 'wt-refused' + const first = launchIntent(worktreeId, 'session-first') + const second = launchIntent(worktreeId, 'session-second') + mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second) + mocks.launch + .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + .mockResolvedValueOnce(second.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, second.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledTimes(2) + expect(mocks.launch.mock.calls[0]?.[0]).toBe(first) + expect(mocks.launch.mock.calls[1]?.[0]).toBe(second) + expect(toast.error).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts new file mode 100644 index 00000000000..f826ab58cee --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -0,0 +1,136 @@ +import { toast } from 'sonner' +import { + createStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession, + StructuredAgentSessionCreateRefusalError, + type StructuredAgentSessionLaunchIntent +} from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { translate } from '@/i18n/i18n' + +type StructuredLaunchState = { + intent: StructuredAgentSessionLaunchIntent + promise: Promise + visibilityUnknown: boolean +} + +const pendingStructuredLaunchesByWorktree = new Map() + +function trackLaunchSettlement( + worktreeId: string, + state: StructuredLaunchState, + promise: Promise +): void { + void promise.then( + () => { + if ( + state.promise === promise && + pendingStructuredLaunchesByWorktree.get(worktreeId) === state + ) { + pendingStructuredLaunchesByWorktree.delete(worktreeId) + } + }, + () => { + if ( + state.promise === promise && + !state.visibilityUnknown && + pendingStructuredLaunchesByWorktree.get(worktreeId) === state + ) { + pendingStructuredLaunchesByWorktree.delete(worktreeId) + } + } + ) +} + +async function verifyPublishedSession(intent: StructuredAgentSessionLaunchIntent): Promise { + const snapshots = await refreshLocalStructuredSessionTabs() + const published = snapshots.some( + (snapshot) => + snapshot.worktree === intent.worktreeId && + snapshot.tabs.some( + (tab) => tab.type === 'agent-session' && tab.sessionId === intent.sessionId + ) + ) + if (!published) { + throw new Error('structured session tab publication unavailable') + } + return intent.sessionId +} + +async function retrySameIntent(state: StructuredLaunchState, priorError: unknown): Promise { + try { + await launchStructuredCodexSession(state.intent) + return await verifyPublishedSession(state.intent) + } catch (error) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { + throw error + } + try { + return await verifyPublishedSession(state.intent) + } catch { + state.visibilityUnknown = true + throw error ?? priorError + } + } +} + +async function launchAndReconcile(state: StructuredLaunchState): Promise { + try { + await launchStructuredCodexSession(state.intent) + } catch (error) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { + throw error + } + try { + return await verifyPublishedSession(state.intent) + } catch { + return retrySameIntent(state, error) + } + } + try { + return await verifyPublishedSession(state.intent) + } catch (error) { + return retrySameIntent(state, error) + } +} + +async function reconcileUnknownLaunch(state: StructuredLaunchState): Promise { + state.visibilityUnknown = false + try { + return await verifyPublishedSession(state.intent) + } catch (error) { + return retrySameIntent(state, error) + } +} + +function launchStructuredCodexSessionOnce(worktreeId: string): Promise { + const existing = pendingStructuredLaunchesByWorktree.get(worktreeId) + if (existing) { + if (existing.visibilityUnknown) { + existing.promise = reconcileUnknownLaunch(existing) + trackLaunchSettlement(worktreeId, existing, existing.promise) + } + return existing.promise + } + const state: StructuredLaunchState = { + intent: createStructuredCodexSessionLaunchIntent(worktreeId), + promise: Promise.resolve(''), + visibilityUnknown: false + } + state.promise = launchAndReconcile(state) + pendingStructuredLaunchesByWorktree.set(worktreeId, state) + trackLaunchSettlement(worktreeId, state, state.promise) + return state.promise +} + +export function startStructuredCodexLaunch(worktreeId: string): void { + void launchStructuredCodexSessionOnce(worktreeId).catch((error) => { + toast.error( + translate( + 'components.native-chat.structuredSessionLaunchFailed', + 'Could not open Codex chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts new file mode 100644 index 00000000000..94261ccdf5e --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + callRuntimeRpc: vi.fn(async () => ({ ok: true })), + focusGroup: vi.fn(), + setActiveTabType: vi.fn(), + state: { unifiedTabsByWorktree: {} } as Record +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'env-1' +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string + }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) +})) + +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +import { + activateStructuredAgentSessionById, + activateStructuredAgentSessionTab +} from './structured-agent-session-tab-activation' + +describe('activateStructuredAgentSessionTab', () => { + beforeEach(() => { + vi.clearAllMocks() + const tab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + mocks.state = { + unifiedTabsByWorktree: { 'wt-1': [tab] }, + focusGroup: mocks.focusGroup, + activateTab: mocks.activateTab, + setActiveTabType: mocks.setActiveTabType + } + }) + + it('selects the unified tab and synchronizes host focus', () => { + expect( + activateStructuredAgentSessionTab({ worktreeId: 'wt-1', tabId: 'structured-tab-1' }) + ).toBe(true) + + expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') + expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'session.tabs.activate', + { worktree: 'id:wt-1', tabId: 'agent-session:session-1' } + ) + }) + + it('routes a provider-owned vault row through its structured session id', () => { + expect(activateStructuredAgentSessionById({ worktreeId: 'wt-1', sessionId: 'session-1' })).toBe( + true + ) + expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'session.tabs.activate', + { worktree: 'id:wt-1', tabId: 'agent-session:session-1' } + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts new file mode 100644 index 00000000000..e4d6eed92bc --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -0,0 +1,43 @@ +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' + +export function activateStructuredAgentSessionTab(args: { + worktreeId: string + tabId: string +}): boolean { + const state = useAppStore.getState() + const tab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => candidate.id === args.tabId && candidate.contentType === 'agent-session' + ) + if (!tab) { + return false + } + state.focusGroup(args.worktreeId, tab.groupId) + state.activateTab(tab.id, { worktreeId: args.worktreeId }) + state.setActiveTabType('agent-session') + const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + void callRuntimeRpc( + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + 'session.tabs.activate', + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: `agent-session:${tab.entityId}` + } + ) + return true +} + +export function activateStructuredAgentSessionById(args: { + worktreeId: string + sessionId: string +}): boolean { + const tab = (useAppStore.getState().unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => + candidate.contentType === 'agent-session' && candidate.entityId === args.sessionId + ) + return tab + ? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id }) + : false +} diff --git a/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts new file mode 100644 index 00000000000..32fe82fe343 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts @@ -0,0 +1,15 @@ +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +/** Old structured projections persisted their desktop id as if a terminal could resume it. */ +export function isStructuredAgentSyntheticSleepingRecord( + record: SleepingAgentSessionRecord +): boolean { + const pane = parsePaneKey(record.paneKey) + return ( + pane !== null && + record.providerSession.key === 'session_id' && + structuredAgentSessionTabId(record.providerSession.id) === pane.tabId + ) +} diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts new file mode 100644 index 00000000000..770413208cc --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type * as localPreflightContext from '@/lib/local-preflight-context' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { canUseStructuredNativeChat } from './structured-native-chat-availability' + +const { mockGetRendererAppPlatform } = vi.hoisted(() => ({ + mockGetRendererAppPlatform: vi.fn<() => NodeJS.Platform>(() => 'darwin') +})) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: mockGetRendererAppPlatform +})) + +type GetLocalProjectExecutionRuntimeContext = + typeof localPreflightContext.getLocalProjectExecutionRuntimeContext + +const projectRuntimeMock = vi.hoisted(() => ({ + fn: vi.fn(), + actual: undefined as GetLocalProjectExecutionRuntimeContext | undefined +})) + +vi.mock('@/lib/local-preflight-context', async (importOriginal) => { + const actual = await importOriginal() + projectRuntimeMock.actual = actual.getLocalProjectExecutionRuntimeContext + return { ...actual, getLocalProjectExecutionRuntimeContext: projectRuntimeMock.fn } +}) + +const wslRuntimeResolution: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1|wsl|Ubuntu' + } +} + +const repairRequiredResolution: ProjectExecutionRuntimeResolution = { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'project-override', + cacheKey: 'repo-1|wsl|repair' + } +} + +function stateFor(input: { + connectionId?: string | null + windowsRuntime?: 'windows-host' | 'wsl' + worktreePath?: string +}): AppState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: { experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: input.worktreePath ?? 'C:\\repo\\worktree' + } + ] + }, + detectedWorktreesByRepo: {} + } as unknown as AppState +} + +describe('canUseStructuredNativeChat', () => { + beforeEach(() => { + mockGetRendererAppPlatform.mockReturnValue('darwin') + projectRuntimeMock.fn.mockReset() + projectRuntimeMock.fn.mockImplementation((...args) => { + if (!projectRuntimeMock.actual) { + throw new Error('real getLocalProjectExecutionRuntimeContext was never captured') + } + return projectRuntimeMock.actual(...args) + }) + }) + + it('allows the structured stack on a local worktree', () => { + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it('keeps the legacy bridge when the updated runtime is opted out', () => { + expect( + canUseStructuredNativeChat( + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a stale structured opt-in while the default view is Terminal chat', () => { + expect( + canUseStructuredNativeChat( + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: false + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a structured opt-in when the default view was never chosen', () => { + expect( + canUseStructuredNativeChat( + { ...stateFor({}), settings: { experimentalStructuredNativeChat: true } } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses an SSH worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'ssh-a' }), 'wt-1')).toBe(false) + }) + + it('refuses a runtime-paired worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'runtime-ssh-a' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a WSL project on Windows so the pane stays on the bridge', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'wsl' }), 'wt-1')).toBe(false) + }) + + it('keeps Windows-host projects on the terminal path until native start-time proof is advertised', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a Windows folder workspace even though its key resolves no project runtime', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + const state = { + ...stateFor({}), + activeRepoId: null, + activeWorktreeId: null + } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(false) + }) + + it('allows a folder workspace on a non-Windows platform', () => { + const state = { + ...stateFor({}), + activeRepoId: null, + activeWorktreeId: null + } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(true) + }) + + it.each(['darwin', 'linux'] as const)('allows a supported local worktree on %s', (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it.each(['darwin', 'linux'] as const)( + 'refuses a WSL project runtime even when the renderer reports %s', + (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + projectRuntimeMock.fn.mockReturnValue(wslRuntimeResolution) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(false) + } + ) + + it.each(['darwin', 'linux'] as const)( + 'refuses a repair-required runtime even when the renderer reports %s', + (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + projectRuntimeMock.fn.mockReturnValue(repairRequiredResolution) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(false) + } + ) +}) diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts new file mode 100644 index 00000000000..bc14ccfd4f0 --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -0,0 +1,30 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' + +export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { + if (state.settings?.experimentalStructuredNativeChat !== true) { + return false + } + // Structured chat has no entry path of its own — it reuses the Chat UI default view. With + // Terminal chat selected the toggle is hidden but its persisted value survives, so gate on the + // default view too or a stale `true` would silently route new tabs into the structured runtime. + if (state.settings?.openAgentTabsInChatByDefault !== true) { + return false + } + if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { + return false + } + // The shipped Windows process-tree addon may not expose creation time. Until + // the host advertises that proof, refuse every local Windows execution path — + // windows-host, WSL, and keys that resolve no project runtime (folder + // workspaces, floating terminal) — so create cannot fail after the click. + if (getRendererAppPlatform() === 'win32') { + return false + } + // Refuse WSL and repair-required runtimes even if resolution ever runs + // off-win32; the gate must not depend on the resolver's platform guard. + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + return !(projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') +} diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index 59515b5699a..8ec344f0544 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.ts @@ -1,6 +1,6 @@ import type { OpenFile } from '@/store/slices/editor' import type { PaletteDocument } from './palette-match/palette-document' -import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' @@ -49,7 +49,7 @@ export type SearchableWorkspaceTab = { // secondary crowds the row. Keep these matchable so typing "terminal" still finds them. export const TERMINAL_TYPE_SEARCH_ALIASES = ['terminal tab', 'terminal'] as const -type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type WorkspaceTabPaletteActiveTabType = WorkspaceVisibleTabType export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & { worktrees: readonly Worktree[] diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index dc83cec79bf..1717cba7408 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -14,7 +14,12 @@ import { setWorktreeNavActivator, setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' +import { + gateWorktreeAgentActivation, + workspaceHasSleepingAgentSessions +} from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { @@ -62,6 +67,14 @@ function ensureFolderWorkspaceInitialTerminal( return primaryTabId } +function canInspectAgentActivationInventory(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.api?.runtime?.call === 'function' && + typeof window.api?.pty?.listSessions === 'function' + ) +} + export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: { @@ -120,12 +133,31 @@ export function activateAndRevealFolderWorkspace( if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } - resumeSleepingAgentSessionsForWorktree(workspaceKey) - const primaryTabId = ensureFolderWorkspaceInitialTerminal( - folderWorkspace, - opts?.startup, - opts?.providesInitialSurface - ) + // Why: same ordering as the worktree path — gate first, then resume only when not deferring. + const shouldGateAgentActivation = + !opts?.startup && + (workspaceHasSleepingAgentSessions(state, workspaceKey) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount + ))) + if (!shouldGateAgentActivation) { + resumeSleepingAgentSessionsForWorktree(workspaceKey) + } + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { + if (outcome === 'empty' && useAppStore.getState().activeWorktreeId === workspaceKey) { + ensureFolderWorkspaceInitialTerminal(folderWorkspace) + } + }) + } + const primaryTabId = shouldGateAgentActivation + ? null + : ensureFolderWorkspaceInitialTerminal( + folderWorkspace, + opts?.startup, + opts?.providesInitialSurface + ) if (opts?.sidebarRevealBehavior) { state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) @@ -205,24 +237,47 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } - // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically. - // Ordering is load-bearing: resuming synchronously creates the session's tab first, so the - // seeding below sees a renderable surface and doesn't add a bare shell next to it. - resumeSleepingAgentSessionsForWorktree(worktreeId) + // Why: the gate is decided BEFORE resuming. A sleeping session must defer seeding until startup + // restoration is ready (STA-1111) — resuming first would leave nothing to gate on. Structured + // agent inventory hydrates asynchronously too, so an empty tab model can otherwise authorize a + // fallback terminal beside a chat that is about to appear. + const shouldGateAgentActivation = + !hasActivationWork && + (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount + ))) + if (!shouldGateAgentActivation) { + // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should + // restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the + // session's tab first, so the seeding below doesn't add a bare shell next to it. + resumeSleepingAgentSessionsForWorktree(worktreeId) + } + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(worktreeId).then((outcome) => { + const currentState = useAppStore.getState() + if (outcome === 'empty' && currentState.activeWorktreeId === worktreeId) { + ensureWorktreeHasInitialTerminal(currentState, worktreeId) + } + }) + } // 4. Ensure a focusable surface exists for externally-created worktrees - const primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktreeId, - opts?.startup, - opts?.setup, - opts?.issueCommand, - opts?.defaultTabs, - { - ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), - reseedEmptiedWorkspace: opts?.providesInitialSurface !== true - } - ) + const primaryTabId = shouldGateAgentActivation + ? null + : ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts?.startup, + opts?.setup, + opts?.issueCommand, + opts?.defaultTabs, + { + ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), + reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + } + ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts new file mode 100644 index 00000000000..3b884affca1 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { TerminalSlice } from '@/store/slices/terminals' +import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' + +const WORKTREE_ID = 'repo::/worktree' +const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' +const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +function listed(id: string): PtyListedSession { + return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' } +} + +function sleepingRecord( + tabId: string, + leafId: string, + providerSessionId: string +): SleepingAgentSessionRecord { + return { + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: providerSessionId }, + prompt: 'resume', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +function runtimeSnapshot( + tabId: string, + leafId: string, + ptyIds: string[] +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'packaged-run-31759132745', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + ...ptyIds.map((ptyId, index) => ({ + type: 'terminal' as const, + id: `${tabId}:${leafId}:${index}`, + title: 'Codex', + parentTabId: tabId, + leafId, + ptyId, + status: 'ready' as const, + terminal: `term-${index}`, + isActive: false + })), + { + type: 'agent-session' as const, + id: 'structured-agent-session-live-session', + title: 'Codex Chat', + sessionId: 'live-session', + agent: 'codex' as const, + isActive: false + } + ] + } +} + +function testDeps(args: { + sessions?: PtyListedSession[] + sleeping?: SleepingAgentSessionRecord[] + structured?: boolean + resumeCount?: number +}) { + const resume = vi.fn(() => args.resumeCount ?? 1) + const sleeping = args.sleeping ?? [] + const ptyIdsByTabId: Record = {} + const tabsByWorktree: Record = { [WORKTREE_ID]: [] } + let createdCount = 0 + const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => { + createdCount += 1 + const id = options?.id ?? `created-${createdCount}` + const tab: TerminalTab = { + id, + ptyId: options?.initialPtyId ?? null, + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: tabsByWorktree[worktreeId]?.length ?? 0, + createdAt: 1 + } + tabsByWorktree[worktreeId] ??= [] + tabsByWorktree[worktreeId].push(tab) + ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : [] + return tab + }) + const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => { + ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])] + }) + const store = { + createTab, + ptyIdsByTabId, + tabsByWorktree, + sleepingAgentSessionsByPaneKey: Object.fromEntries( + sleeping.map((record) => [record.paneKey, record]) + ), + updateTabPtyId, + replaceTerminalLayoutPanePtyId: vi.fn(), + terminalLayoutsByTabId: Object.fromEntries( + sleeping.map((record) => { + const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1) + return [ + record.tabId!, + { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + ] + }) + ), + unifiedTabsByWorktree: { + [WORKTREE_ID]: args.structured + ? [ + { + id: 'structured-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'agent-session' as const, + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + : [] + } + } + return { + createTab, + resume, + deps: { + getState: () => store, + listSessions: vi.fn(async () => args.sessions ?? []), + resume + } + } +} + +describe('worktree agent activation gate', () => { + it('uses immediately ready development restore inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const awaitReady = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + ).resolves.toBe('adopted') + + expect(awaitReady).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('waits for packaged restore hydration before reading daemon inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + let releaseReady!: (ready: boolean) => void + const awaitReady = vi.fn(() => new Promise((resolve) => (releaseReady = resolve))) + + const activation = runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce()) + expect(deps.listSessions).not.toHaveBeenCalled() + + releaseReady(true) + await expect(activation).resolves.toBe('adopted') + expect(deps.listSessions).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('reads structured ownership before adopting daemon PTYs', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps } = testDeps({ sessions: [listed(ptyId)] }) + const hasStructuredSession = vi.fn(async () => false) + + await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + + expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan( + deps.listSessions.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('blocks automatic resume when packaged restore never becomes ready', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + awaitReady: async () => false + }) + ).resolves.toBe('blocked') + + expect(deps.listSessions).not.toHaveBeenCalled() + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('adopts a live daemon PTY before activation can resume another agent', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('adopts a daemon PTY minted for a folder workspace', async () => { + const folderWorkspaceId = 'folder:plain-workspace' + const ptyId = `${folderWorkspaceId}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the workspace has only a structured session', async () => { + const { deps, createTab, resume } = testDeps({ structured: true }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('structured') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('does not activate a terminal for a stale structured sleeping projection', async () => { + const tabId = structuredAgentSessionTabId(STALE_STRUCTURED_SESSION_ID) + const stale = sleepingRecord(tabId, LIVE_LEAF_ID, STALE_STRUCTURED_SESSION_ID) + const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [stale] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the runtime reports a structured session before tab sync', async () => { + const { deps, createTab, resume } = testDeps({}) + const hasStructuredSession = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('structured') + + expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID) + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('keeps the existing resume path when the workspace has no live agent', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).toHaveBeenCalledOnce() + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('resumes a dead agent when the workspace only has a non-agent PTY', async () => { + const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session') + const plainPtyId = `${WORKTREE_ID}@@plain-shell` + const { deps, resume } = testDeps({ + sessions: [{ ...listed(plainPtyId), title: 'zsh', agentOwnership: 'absent' }], + sleeping: [dead] + }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('suppresses only the exact live agent session while resuming a dead sibling', async () => { + const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') + const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live, dead] }) + const store = deps.getState() + store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = livePtyId + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('adopts the exact structured TUI surface without creating a duplicate tab', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, createTab, resume } = testDeps({ + sessions: [listed(livePtyId)], + sleeping: [live], + resumeCount: 0 + }) + const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d' + const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345' + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { + paneKey: `${ownerTabId}:${ownerLeafId}`, + ptyId: livePtyId, + tabId: ownerTabId + } + } + ] + ]) + }) + }) + ).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + id: ownerTabId, + initialLeafId: ownerLeafId, + initialPtyId: livePtyId, + activate: false, + recordInteraction: false + }) + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('does not use an ambiguous tab binding as a live session claim', async () => { + const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + deps.getState().ptyIdsByTabId['tab-live'] = [livePtyId, `${WORKTREE_ID}@@other-agent`] + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('blocks when a structured TUI owner is absent from live inventory', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { + paneKey: live.paneKey, + ptyId: `${WORKTREE_ID}@@different-agent`, + tabId + } + } + ] + ]) + }) + }) + ).resolves.toBe('blocked') + + expect(resume).not.toHaveBeenCalled() + }) + + it('uses the restored owner surface even when its tab predates structured naming', async () => { + const tabId = 'structured-agent-session-other-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { paneKey: live.paneKey, ptyId: livePtyId, tabId } + } + ] + ]) + }) + }) + ).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('suppresses the exact structured session when native already owns it', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const { deps, resume } = testDeps({ sleeping: [live], resumeCount: 0 }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, []), + ownerBySessionId: new Map([['live-session', { owner: 'native' }]]) + }) + }) + ).resolves.toBe('structured') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts new file mode 100644 index 00000000000..1b0237b6504 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -0,0 +1,278 @@ +import { useAppStore } from '@/store' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + resumeSleepingAgentSessionsForWorktree, + type ResumeSleepingAgentSessionsOptions +} from './resume-sleeping-agent-session' +import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' +import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' +import { + readWorktreeStructuredActivationInventory, + type StructuredActivationInventory +} from './worktree-agent-structured-inventory' + +type ActivationStore = Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' +> + +type ActivationGateDeps = { + getState: () => ActivationStore + awaitReady?: () => Promise + listSessions: () => Promise + hasStructuredSession?: (worktreeId: string) => Promise + resume: (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number +} + +export type WorktreeAgentActivationOutcome = + | 'adopted' + | 'structured' + | 'resumed' + | 'empty' + | 'blocked' + +const inFlightByWorktreeId = new Map>() +const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000 + +function waitForWorkspaceSessionReady(): Promise { + const isReady = () => { + const state = useAppStore.getState() + return state.workspaceSessionReady && state.terminalStartupRestorationReady + } + if (isReady()) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let unsubscribe: (() => void) | null = null + const settle = (ready: boolean) => { + clearTimeout(timeout) + unsubscribe?.() + resolve(ready) + } + const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS) + unsubscribe = useAppStore.subscribe((state) => { + if (state.workspaceSessionReady && state.terminalStartupRestorationReady) { + settle(true) + } + }) + if (isReady()) { + settle(true) + } + }) +} + +export function workspaceHasSleepingAgentSessions( + state: Pick, 'sleepingAgentSessionsByPaneKey'>, + worktreeId: string +): boolean { + return Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === worktreeId + ) +} + +function hasStructuredSession(store: ActivationStore, worktreeId: string): boolean { + return (store.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.contentType === 'agent-session' + ) +} + +function ptyIsAlreadyBound(store: ActivationStore, ptyId: string): boolean { + return Object.values(store.ptyIdsByTabId).some((ids) => ids.includes(ptyId)) +} + +function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean { + if (parsePtySessionId(sessionId).worktreeId === worktreeId) { + return true + } + const scope = parseWorkspaceKey(worktreeId) + return ( + scope?.type === 'folder' && + sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) && + sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length + ) +} + +function liveSleepingAgentClaimKeys( + store: ActivationStore, + worktreeId: string, + livePtyIds: ReadonlySet, + structuredInventory: StructuredActivationInventory | null +): Set { + const keys = new Set() + for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId !== worktreeId) { + continue + } + const stable = parsePaneKey(record.paneKey) + const tabId = record.tabId ?? stable?.tabId + const layoutPtyId = stable + ? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId] + : undefined + const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined + const structuredOwner = + stable && isStructuredAgentSyntheticSleepingRecord(record) + ? structuredInventory?.ownerBySessionId.get(record.providerSession.id) + : undefined + if (structuredOwner?.owner === 'native') { + keys.add(getProviderSessionClaimKey(record)) + continue + } + // Packaged hydration can omit renderer bindings while main retains this session's exact TUI. + const structuredOwnerPtyId = + structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined + const persistedPtyId = + layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId + if (persistedPtyId && livePtyIds.has(persistedPtyId)) { + keys.add(getProviderSessionClaimKey(record)) + } + } + return keys +} + +export async function runWorktreeAgentActivationGate( + worktreeId: string, + deps: ActivationGateDeps +): Promise { + try { + if (deps.awaitReady && !(await deps.awaitReady())) { + return 'blocked' + } + } catch { + return 'blocked' + } + let structured = false + let structuredInventory: StructuredActivationInventory | null = null + try { + const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId) + structuredInventory = + typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null + structured = Boolean( + hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession + ) + } catch { + return 'blocked' + } + + const structuredTabs = structuredInventory?.snapshot.tabs.filter( + (tab) => tab.type === 'agent-session' + ) + if ( + structuredTabs?.some((tab) => { + const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId) + return ( + !owner || + (owner.owner === 'tui' && + (!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId)) + ) + }) + ) { + return 'blocked' + } + if ( + structured && + !structuredInventory && + workspaceHasSleepingAgentSessions(deps.getState(), worktreeId) + ) { + return 'blocked' + } + + let sessions: PtyListedSession[] + try { + sessions = await deps.listSessions() + } catch { + // Inventory uncertainty cannot authorize a second writer. + return 'blocked' + } + + const liveWorkspaceSessions = sessions.filter((session) => + sessionBelongsToWorkspace(session.id, worktreeId) + ) + const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id)) + for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) { + if (owner.owner !== 'tui') { + continue + } + if ( + !owner.terminal || + !liveWorkspacePtyIds.has(owner.terminal.ptyId) || + !bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal) + ) { + return 'blocked' + } + } + if (liveWorkspaceSessions.length > 0) { + for (const session of liveWorkspaceSessions) { + const store = deps.getState() + if (ptyIsAlreadyBound(store, session.id)) { + continue + } + store.createTab(worktreeId, undefined, undefined, { + initialPtyId: session.id, + activate: false, + recordInteraction: false + }) + } + if (!workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'adopted' + } + } + + if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'structured' + } + const launched = deps.resume(worktreeId, { + skipClaimKeys: liveSleepingAgentClaimKeys( + deps.getState(), + worktreeId, + liveWorkspacePtyIds, + structuredInventory + ) + }) + return launched > 0 + ? 'resumed' + : liveWorkspaceSessions.length > 0 + ? 'adopted' + : structured + ? 'structured' + : 'empty' +} + +export function gateWorktreeAgentActivation( + worktreeId: string +): Promise { + const existing = inFlightByWorktreeId.get(worktreeId) + if (existing) { + return existing + } + const gate = runWorktreeAgentActivationGate(worktreeId, { + getState: () => useAppStore.getState(), + awaitReady: waitForWorkspaceSessionReady, + listSessions: () => + typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(), + hasStructuredSession: readWorktreeStructuredActivationInventory, + resume: resumeSleepingAgentSessionsForWorktree + }).finally(() => { + if (inFlightByWorktreeId.get(worktreeId) === gate) { + inFlightByWorktreeId.delete(worktreeId) + } + }) + inFlightByWorktreeId.set(worktreeId, gate) + return gate +} + +export function waitForWorktreeAgentActivationGateForTests( + worktreeId: string +): Promise { + return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null) +} diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts new file mode 100644 index 00000000000..382b86105bd --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -0,0 +1,204 @@ +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' + +const initialState = useAppStore.getState() + +function baseState(): Partial { + const worktree = makeWorktree() + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + settings: { + agentCmdOverrides: {}, + defaultTuiAgent: 'codex', + setupScriptLaunchMode: 'new-tab' + } as AppState['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } +} + +function structuredSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'activation-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'structured-agent-session-chat-1', + title: 'Codex Chat', + sessionId: 'chat-1', + agent: 'codex', + isActive: false + } + ] + } +} + +function stubInventory(args?: { structured?: boolean; livePtyId?: string }): { + runtimeCall: ReturnType + listSessions: ReturnType +} { + const worktree = makeWorktree() + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.listAll') { + return { + ok: true, + result: { snapshots: args?.structured ? [structuredSnapshot(worktree.id)] : [] } + } + } + if (method === 'agentSession.handoffStatus') { + return { ok: true, result: { owner: 'native' } } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const listSessions = vi.fn(async () => + args?.livePtyId + ? [ + { + id: args.livePtyId, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ] + : [] + ) + vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, pty: { listSessions } } }) + return { runtimeCall, listSessions } +} + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +describe('worktree agent activation seam', () => { + it('keeps a projected chat-only workspace terminal-free', async () => { + const worktree = makeWorktree() + const groupId = 'chat-group' + useAppStore.setState({ + ...baseState(), + unifiedTabsByWorktree: { + [worktree.id]: [ + { + id: 'structured-agent-session-chat-1', + entityId: 'chat-1', + groupId, + worktreeId: worktree.id, + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [worktree.id]: [ + { + id: groupId, + worktreeId: worktree.id, + activeTabId: 'structured-agent-session-chat-1', + tabOrder: ['structured-agent-session-chat-1'] + } + ] + }, + activeGroupIdByWorktree: { [worktree.id]: groupId } + }) + const { runtimeCall, listSessions } = stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).not.toHaveBeenCalled() + expect(listSessions).not.toHaveBeenCalled() + }) + + it('adopts a live terminal without spawning a fallback', async () => { + const worktree = makeWorktree() + const livePtyId = `${worktree.id}@@live-codex` + useAppStore.setState(baseState()) + stubInventory({ livePtyId }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBe(livePtyId) + }) + + it('spawns a fallback when the workspace has no agent', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBeNull() + }) + + it('does not spawn before a structured chat tab hydrates', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + const { runtimeCall } = stubInventory({ structured: true }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).toHaveBeenCalledWith({ method: 'session.tabs.listAll', params: {} }) + expect(runtimeCall).toHaveBeenCalledWith({ + method: 'agentSession.handoffStatus', + params: { sessionId: 'chat-1' } + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts new file mode 100644 index 00000000000..df95951ed76 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -0,0 +1,65 @@ +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { useAppStore } from '@/store' + +type LiveSurfaceAdoptionStore = Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' +> + +function layoutContainsLeaf( + root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'], + leafId: string +): boolean { + if (!root) { + return false + } + return root.type === 'leaf' + ? root.leafId === leafId + : layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId) +} + +export function bindLivePtyToExactSurface( + store: LiveSurfaceAdoptionStore, + worktreeId: string, + terminal: { paneKey: string; ptyId: string; tabId: string } +): boolean { + const pane = parsePaneKey(terminal.paneKey) + if (!pane || pane.tabId !== terminal.tabId) { + return false + } + const ownerEntries = Object.entries(store.tabsByWorktree).flatMap(([ownerWorktreeId, tabs]) => + tabs.filter((tab) => tab.id === terminal.tabId).map((tab) => ({ ownerWorktreeId, tab })) + ) + const competingBinding = Object.entries(store.ptyIdsByTabId).some( + ([tabId, ptyIds]) => tabId !== terminal.tabId && ptyIds.includes(terminal.ptyId) + ) + if (ownerEntries.length > 1 || competingBinding) { + return false + } + const existing = ownerEntries[0] + if (existing) { + const layout = store.terminalLayoutsByTabId[terminal.tabId] + if ( + existing.ownerWorktreeId !== worktreeId || + !layoutContainsLeaf(layout?.root ?? null, pane.leafId) + ) { + return false + } + store.updateTabPtyId(terminal.tabId, terminal.ptyId) + store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId) + return true + } + const created = store.createTab(worktreeId, undefined, undefined, { + id: terminal.tabId, + initialLeafId: pane.leafId, + initialPtyId: terminal.ptyId, + activate: false, + recordInteraction: false + }) + return created.id === terminal.tabId +} diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts new file mode 100644 index 00000000000..dc58e5cec71 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts @@ -0,0 +1,78 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +export type StructuredActivationInventory = { + snapshot: RuntimeMobileSessionTabsResult + ownerBySessionId: ReadonlyMap< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + > +} + +export async function readWorktreeStructuredActivationInventory( + worktreeId: string +): Promise { + if (typeof window === 'undefined') { + return false + } + const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }) + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshot = (result.snapshots ?? []).find( + (candidate) => + candidate.worktree === worktreeId && + candidate.tabs.some((tab) => tab.type === 'agent-session') + ) + if (!snapshot) { + return false + } + const ownerBySessionId = new Map< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + >() + await Promise.all( + snapshot.tabs.flatMap((tab) => + tab.type === 'agent-session' + ? [ + window.api.runtime + .call({ method: 'agentSession.handoffStatus', params: { sessionId: tab.sessionId } }) + .then((statusResponse) => { + if (!statusResponse.ok) { + return + } + const status = statusResponse.result as { + owner?: unknown + terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown } + } + if (status.owner === 'native') { + ownerBySessionId.set(tab.sessionId, { owner: 'native' }) + } else if ( + status.owner === 'tui' && + typeof status.terminal?.paneKey === 'string' && + typeof status.terminal?.ptyId === 'string' && + status.terminal.ptyId.length > 0 && + typeof status.terminal.tabId === 'string' + ) { + ownerBySessionId.set(tab.sessionId, { + owner: 'tui', + terminal: { + paneKey: status.terminal.paneKey, + ptyId: status.terminal.ptyId, + tabId: status.terminal.tabId + } + }) + } + }) + ] + : [] + ) + ) + return { snapshot, ownerBySessionId } +} diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 9ac7e28ed25..e052b75f7d4 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -42,6 +42,8 @@ function setTabs( ): void { useAppStore.setState({ tabsByWorktree: { 'wt-1': tabs }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, worktreesByRepo: { 'repo-1': [ { @@ -143,18 +145,56 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { expect(tabViewMode('agent-tab')).toBe('terminal') }) - it('opens a backend-spawned mirrorable draft in chat after host reconciliation', () => { + it('opens a backend-spawned mirrorable draft in chat after host reconciliation', async () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }]) - - seedAgentTabStateAfterWorktreeCreate({ - request, - worktreeId: 'wt-1', - primaryTabId: 'agent-tab', - startupTerminalTabId: 'agent-tab', - backendSpawned: true + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'agentSession.handoffStatus') { + return { + id: 'status', + ok: true, + result: { owner: 'native', direction: null, phase: 'idle' }, + _meta: { runtimeId: 'runtime-1' } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { api: { runtime: { call: runtimeCall } } } + }) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'agent-tab': { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': 'pty-1' } + } as never + }, + agentStatusByPaneKey: { + 'agent-tab:leaf-1': { + agentType: 'claude', + providerSession: { id: 'claude-session-1' } + } as never + } }) - expect(tabViewMode('agent-tab')).toBe('chat') + try { + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + await vi.waitFor(() => expect(tabViewMode('agent-tab')).toBe('chat')) + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } }) it('still opens a local omp draft in chat, despite the local-transcript gate', () => { diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index ff22ddee07d..75be4ea395e 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -37,6 +38,8 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -132,7 +135,7 @@ afterEach(() => { }) describe('preserved-pane replacement contract on workspace activation', () => { - it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', () => { + it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Hibernation cleared the pane's PTY binding: the husk cannot resume in place. @@ -141,6 +144,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-A') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const afterFirst = useAppStore.getState() const tabsAfterFirst = afterFirst.tabsByWorktree[worktree.id] ?? [] @@ -163,7 +167,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(afterRepeats.tabsByWorktree[worktree.id]).toHaveLength(2) }) - it('does not append a replacement while the preserved pane still has a live PTY', () => { + it('does not append a replacement while the preserved pane still has a live PTY', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-live-1') @@ -172,6 +176,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-B') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -179,7 +184,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not fork a NON-group-active restorable pane into a replacement tab', () => { + it('does not fork a NON-group-active restorable pane into a replacement tab', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-old-1') @@ -224,6 +229,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { activateAndRevealWorktree(worktree.id) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([ @@ -235,7 +241,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not append a replacement when the preserved pane will cold-restore in place', () => { + it('does not append a replacement when the preserved pane will cold-restore in place', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Restorable binding persists, no live PTY: pane-level cold restore owns recovery. @@ -246,6 +252,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-C') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -258,7 +265,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // the replacement it used to append relaunched `codex resume` against a // session the host still held (-32600 "already has an active writer"), // stranding a bare shell while the live agent lost its tab. - it('parks the resume for a still-published web-mirror tab and replays it on the verdict', () => { + it('parks the resume for a still-published web-mirror tab and replays it on the verdict', async () => { const webTabId = 'web-terminal-host-tab' // The workspace is owned by a paired runtime — without that the deferral // takes its no-execution-host early return and pins nothing. @@ -320,7 +327,8 @@ describe('preserved-pane replacement contract on workspace activation', () => { RUNTIME_ENV_ID ) - activateAndRevealWorktree(worktree.id) + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([webTabId]) @@ -345,7 +353,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // Why: retraction is the mirror's verdict that the host pane is gone, which // is what re-arms the one-replacement-per-session contract above. - it('appends the replacement once the mirror has retracted the web-mirror tab', () => { + it('appends the replacement once the mirror has retracted the web-mirror tab', async () => { const webTabId = 'web-terminal-host-tab' const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) @@ -379,6 +387,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts index 84bc814e461..03253f7220f 100644 --- a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts @@ -4,6 +4,7 @@ import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' // Red repro for the aug20 "windows 2" incident (restart-reattach/resume-relaunch): // a runtime-owned (paired remote) worktree's web-mirror tab holds a sleeping @@ -161,9 +162,16 @@ describe('runtime-owned worktree activation with an unhydrated host mirror', () expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('control: a local worktree with a dead pane still gets the resume fallback', () => { - const worktree = { ...makeCreatedAgentWorktree(), createdWithAgent: undefined } + it('control: a local worktree with a dead pane still gets the resume fallback', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + createdWithAgent: undefined, + hostId: 'local' as const + } const state = baseState(worktree) + state.activeWorkspaceExecutionHostId = null + state.workspaceSessionReady = true + state.terminalStartupRestorationReady = true // Local husk tab: same shape, non-mirror tab id. const localTabId = 'husk-tab-1' state.tabsByWorktree = { @@ -217,6 +225,7 @@ describe('runtime-owned worktree activation with an unhydrated host mirror', () })) activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index a0c24f24517..ff6731cb810 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' const initialAppStoreState = useAppStore.getState() @@ -20,6 +21,8 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -48,13 +51,92 @@ function baseState(worktree: ReturnType): Partial } afterEach(() => { + vi.unstubAllGlobals() useAppStore.setState(initialAppStoreState, true) }) describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { - it('re-captured sleeping codex session resumes once, not once per reopen', () => { + it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => { + const worktree = { ...makeWorktree(), createdWithAgent: undefined } + useAppStore.setState({ + ...baseState(worktree), + workspaceSessionReady: false, + terminalStartupRestorationReady: false + }) + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `packaged-restart-pane:${leafId}` + const livePtyId = `${worktree.id}@@daemon-live` + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'packaged-restart-pane', + worktreeId: worktree.id, + agent: 'codex', + providerSession: { key: 'session_id', id: 'packaged-session' }, + prompt: 'resume prior task', + state: 'working', + origin: 'quit', + capturedAt: 1000, + updatedAt: 1000, + terminalTitle: 'Codex' + } + }, + terminalLayoutsByTabId: { + 'packaged-restart-pane': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: livePtyId } + } + } + }) + vi.stubGlobal('window', { + api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, + pty: { + listSessions: vi.fn(async () => [ + { + id: livePtyId, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ]) + } + } + }) + + activateAndRevealWorktree(worktree.id) + const gate = waitForWorktreeAgentActivationGateForTests(worktree.id) + await Promise.resolve() + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + + useAppStore.setState({ + workspaceSessionReady: true, + terminalStartupRestorationReady: true + }) + await gate + const restored = useAppStore.getState() + expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1) + expect(restored.tabsByWorktree[worktree.id]?.[0]?.ptyId).toBe(livePtyId) + expect(restored.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(restored.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + }) + + it('re-captured sleeping codex session resumes once, not once per reopen', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } useAppStore.setState(baseState(worktree)) + vi.stubGlobal('window', { + api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, + pty: { listSessions: vi.fn(async () => []) } + } + }) const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } let resumedTabId: string | undefined @@ -80,6 +162,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const state = useAppStore.getState() const tabs = state.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index 75a88500462..2b99927cb8c 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -144,7 +144,9 @@ describe('host-session-mirror settle census', () => { // singular frame, scoped active frame. 'runtime/web-session-tabs-sync.ts': 5, // The eager post-create session.tabs.list refresh. - 'runtime/web-runtime-session.ts': 1 + 'runtime/web-runtime-session.ts': 1, + // The local structured-session inventory/subscription frame. + 'runtime/local-structured-session-tabs-sync.ts': 1 }) }) @@ -190,7 +192,8 @@ describe('host-session-mirror settle census', () => { // frame patch, and its patchless twin) and three mirror ones // (visibility-resume repair, global singular frame patch and patchless). 'runtime/web-session-tabs-sync.ts': { settleHydration: 4, settleMirror: 3 }, - 'runtime/web-runtime-session.ts': { settleMirror: 1 } + 'runtime/web-runtime-session.ts': { settleMirror: 1 }, + 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } }) }) diff --git a/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts new file mode 100644 index 00000000000..22af26b6544 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { collectClientLayoutGroupIds } from './web-session-client-owned-tab-placement' +import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent' + +// Why: a structured session created on a worktree with no prior tabs must land in a +// group the local layout actually renders. The host publishes it inside a +// "headless-terminals:" group; adopting that group while freezing the local layout +// leaves the tab in store but permanently off screen (empty-worktree launch P0). + +const GIT_WT = 'repo-1::/tmp/wt1' +const FOLDER_WT = 'folder:folder-1' +const LOCAL_ROOT = 'local-root-group' + +afterEach(() => { + resetWebSessionFocusIntentForTests() +}) + +function emptyState(overrides: Partial): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: GIT_WT, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0, + ...overrides + } +} + +function headlessSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'structured:epoch-1', + snapshotVersion: 1, + activeGroupId: `headless-terminals:${worktreeId}`, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: `headless-terminals:${worktreeId}`, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } +} + +function applyStructured( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult +): WebSessionTabsSyncState { + const patch = applyWebSessionTabsSnapshot( + state, + projectLocalStructuredSessionTabs(snapshot), + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true, terminalPtyMode: 'local' } + ) + return { ...state, ...patch } as WebSessionTabsSyncState +} + +/** The visibility contract: the published chat tab sits in a group the layout renders. */ +function expectChatTabRendered(state: WebSessionTabsSyncState, worktreeId: string): Tab { + const chatTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'agent-session' + ) + expect(chatTab).toBeDefined() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const owningGroup = groups.find((group) => group.tabOrder.includes(chatTab!.id)) + expect(owningGroup).toBeDefined() + expect(chatTab!.groupId).toBe(owningGroup!.id) + const renderedGroupIds = collectClientLayoutGroupIds(state.layoutByWorktree[worktreeId] ?? null) + expect(renderedGroupIds.has(owningGroup!.id)).toBe(true) + return chatTab! +} + +describe('structured session visibility on empty worktrees', () => { + it('adopts the session into the rendered local root leaf when its group record is missing (git worktree)', () => { + // The observed P0 store state: the layout leaf exists but its group record does not. + const state = emptyState({ + layoutByWorktree: { [GIT_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [GIT_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(GIT_WT)) + + const chatTab = expectChatTabRendered(applied, GIT_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + expect(applied.layoutByWorktree[GIT_WT]).toEqual({ type: 'leaf', groupId: LOCAL_ROOT }) + }) + + it('materializes a rendered group on a truly empty git worktree', () => { + const applied = applyStructured(emptyState({}), headlessSnapshot(GIT_WT)) + + expectChatTabRendered(applied, GIT_WT) + }) + + it('materializes a rendered group on a truly empty folder workspace', () => { + const applied = applyStructured( + emptyState({ activeWorktreeId: FOLDER_WT }), + headlessSnapshot(FOLDER_WT) + ) + + expectChatTabRendered(applied, FOLDER_WT) + }) + + it('adopts the session into an existing empty local root group', () => { + const state = emptyState({ + groupsByWorktree: { + [GIT_WT]: [{ id: LOCAL_ROOT, worktreeId: GIT_WT, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { [GIT_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [GIT_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(GIT_WT)) + + const chatTab = expectChatTabRendered(applied, GIT_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + }) + + it('keeps the folder-workspace local split intact while placing the session beside the terminal', () => { + const terminalTab: Tab = { + id: 'u-term-1', + entityId: 'term-1', + groupId: LOCAL_ROOT, + worktreeId: FOLDER_WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const state = emptyState({ + activeWorktreeId: FOLDER_WT, + tabsByWorktree: { + [FOLDER_WT]: [ + { + id: 'term-1', + worktreeId: FOLDER_WT, + ptyId: 'pty-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'term-1': ['pty-1'] }, + unifiedTabsByWorktree: { [FOLDER_WT]: [terminalTab] }, + groupsByWorktree: { + [FOLDER_WT]: [ + { + id: LOCAL_ROOT, + worktreeId: FOLDER_WT, + activeTabId: 'u-term-1', + tabOrder: ['u-term-1'] + } + ] + }, + layoutByWorktree: { [FOLDER_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [FOLDER_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(FOLDER_WT)) + + const chatTab = expectChatTabRendered(applied, FOLDER_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + const rootGroup = applied.groupsByWorktree[FOLDER_WT]?.find((group) => group.id === LOCAL_ROOT) + expect(rootGroup?.tabOrder).toEqual(['u-term-1', chatTab.id]) + expect(applied.layoutByWorktree[FOLDER_WT]).toEqual({ type: 'leaf', groupId: LOCAL_ROOT }) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts new file mode 100644 index 00000000000..614eae533af --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { toWebTerminalSurfaceTabId } from './web-runtime-session' +import { + applyLocalStructuredSessionTabSnapshots, + projectLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const REMOTE_GROUP = 'remote-group' +const STRUCTURED_GROUP = 'structured-group' +const HOST_TAB_ID = 'host-tab-1' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) + +function terminalSnapshot(version = 1): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready', + terminal: 'term-host', + isActive: true + } + ], + { + snapshotVersion: version, + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function pendingTerminalSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Starting terminal', + status: 'pending-handle', + terminal: null, + isActive: true + } + ], + { + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function structuredSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ], + { + publicationEpoch: 'structured:epoch-1', + activeGroupId: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { type: 'leaf', groupId: STRUCTURED_GROUP } + } + ) +} + +function applySnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + options?: Parameters[4] +): WebSessionTabsSyncState { + return { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot, environmentId, NOW, options) + } as WebSessionTabsSyncState +} + +function expectRemoteTerminalTopology(state: WebSessionTabsSyncState): void { + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: `remote:${ENV}@@term-host` }) + ]) + expect(state.unifiedTabsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: MIRRORED_TAB_ID, contentType: 'terminal' }) + ]) + ) + expect(state.groupsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: REMOTE_GROUP, + tabOrder: expect.arrayContaining([MIRRORED_TAB_ID]) + }) + ]) + ) + expect(state.activeGroupIdByWorktree[WT]).toBe(REMOTE_GROUP) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + expect(state.activeTabIdByWorktree[WT]).toBe(MIRRORED_TAB_ID) + expect(state.activeTabTypeByWorktree[WT]).toBe('terminal') +} + +describe('local structured session tab host isolation', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('materializes a complete terminal group and layout into an otherwise empty paired state', () => { + const state = applySnapshot(makeState(), terminalSnapshot(), ENV) + + expectRemoteTerminalTopology(state) + }) + + it('materializes the active paired worktree without disturbing another worktree layout', () => { + const otherWorktree = 'repo::/other-worktree' + const otherLayout = { type: 'leaf' as const, groupId: 'other-group' } + const state = applySnapshot( + makeState({ layoutByWorktree: { [otherWorktree]: otherLayout } }), + terminalSnapshot(), + ENV + ) + + expectRemoteTerminalTopology(state) + expect(state.layoutByWorktree[otherWorktree]).toBe(otherLayout) + }) + + it('keeps ordinary remote topology stable across agent-only inventory frames', () => { + let state = applySnapshot(makeState(), terminalSnapshot(), ENV) + state = applySnapshot( + state, + projectLocalStructuredSessionTabs(structuredSnapshot()), + 'local-structured-session', + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + expectRemoteTerminalTopology(state) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + }) + + it('keeps startup terminal topology through pending, ready, and repeated ready frames', () => { + let state = applySnapshot(makeState(), pendingTerminalSnapshot(), ENV) + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: null }) + ]) + expect(state.groupsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: REMOTE_GROUP, tabOrder: [MIRRORED_TAB_ID] }) + ]) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + const stableGroups = state.groupsByWorktree + const stableLayouts = state.layoutByWorktree + + state = applySnapshot(state, terminalSnapshot(3), ENV) + expectRemoteTerminalTopology(state) + expect(state.groupsByWorktree).toBe(stableGroups) + expect(state.layoutByWorktree).toBe(stableLayouts) + }) + + it.each([ + ['paired', toRuntimeExecutionHostId(ENV)], + ['SSH', toSshExecutionHostId('ssh-target-1')] + ])('does not project local structured inventory into a %s-owned workspace', (_name, hostId) => { + const state = makeState({ + activeWorkspaceExecutionHostId: hostId + } as Partial) + + const next = applyLocalStructuredSessionTabSnapshots( + state, + [structuredSnapshot()], + undefined, + NOW + ) + + expect(next).toBe(state) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts new file mode 100644 index 00000000000..4bd9a2e6d45 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -0,0 +1,458 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { buildHydratedTabState } from '../store/slices/tabs-hydration' +import { + applyLocalStructuredSessionTabSnapshots, + projectLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' +import { + recordWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' + +const WORKTREE_ID = 'repo-1::worktree-1' +const TERMINAL_ID = 'terminal-1' +const STRUCTURED_ID = 'structured-agent-session-codex-1' +const PRIMARY_GROUP = 'primary-group' +const SECONDARY_GROUP = 'secondary-group' + +afterEach(() => { + resetWebSessionFocusIntentForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +function createSnapshot(): WebSessionTabsSyncState { + const tabs: Tab[] = [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: STRUCTURED_ID, + entityId: 'codex-1', + groupId: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP }, + activeTabId: STRUCTURED_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: STRUCTURED_ID }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }, + { + id: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + } + }, + openFiles: [], + ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +function expectExactSplit(state: { + unifiedTabsByWorktree: Record + groupsByWorktree: WebSessionTabsSyncState['groupsByWorktree'] + layoutByWorktree: WebSessionTabsSyncState['layoutByWorktree'] + activeGroupIdByWorktree: Record +}): void { + expect(state.layoutByWorktree[WORKTREE_ID]).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + }) + expect(state.groupsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: PRIMARY_GROUP, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }), + expect.objectContaining({ + id: SECONDARY_GROUP, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + }) + ]) + ) + expect(state.groupsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TERMINAL_ID, groupId: PRIMARY_GROUP, contentType: 'terminal' }), + expect.objectContaining({ + id: STRUCTURED_ID, + groupId: SECONDARY_GROUP, + contentType: 'agent-session' + }) + ]) + expect(state.activeGroupIdByWorktree[WORKTREE_ID]).toBe(SECONDARY_GROUP) +} + +describe('local structured session tab projection', () => { + it('drops terminal topology while retaining structured tabs', () => { + const snapshot = { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'structured-group', + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'terminal-group', + activeTabId: 'terminal-1', + tabOrder: ['terminal-1'] + }, + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'terminal-group' }, + second: { type: 'leaf', groupId: 'structured-group' } + }, + tabs: [ + { + type: 'terminal', + id: 'terminal-1', + parentTabId: 'terminal-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ + tabGroups: [ + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: undefined, + tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })] + }) + }) + + it('preserves the exact local split through apply, persistence, and hydration', () => { + const state = createSnapshot() + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] }, + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: state.layoutByWorktree[WORKTREE_ID], + tabs: [ + { + type: 'terminal', + id: TERMINAL_ID, + parentTabId: TERMINAL_ID, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + const projected = projectLocalStructuredSessionTabs(snapshot) + const patch = applyWebSessionTabsSnapshot( + state, + projected, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...state, ...patch } as WebSessionTabsSyncState + + expectExactSplit(applied) + + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...buildPersistedUnifiedTabSessionData(applied) + } + const hydrated = buildHydratedTabState(session, new Set([WORKTREE_ID])) + + expectExactSplit(hydrated) + }) + + it('repairs stale legacy active pointers when restart republishes the native tab', () => { + const state = createSnapshot() + const restartedState: WebSessionTabsSyncState = { + ...state, + activeTabId: TERMINAL_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: TERMINAL_ID }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP } + } + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'structured:restart-1', + snapshotVersion: 1, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session' as const, + tabGroups: [ + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session' as const, + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex' as const, + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + const projected = projectLocalStructuredSessionTabs(snapshot) + const patch = applyWebSessionTabsSnapshot( + restartedState, + projected, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...restartedState, ...patch } as WebSessionTabsSyncState + + expect(applied.activeTabTypeByWorktree[WORKTREE_ID]).toBe('agent-session') + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(STRUCTURED_ID) + }) + + it('honors the focus intent for a newly published local structured tab', () => { + const initial = createSnapshot() + const state: WebSessionTabsSyncState = { + ...initial, + activeGroupIdByWorktree: { [WORKTREE_ID]: PRIMARY_GROUP }, + activeTabId: TERMINAL_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: TERMINAL_ID }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + groupsByWorktree: { + [WORKTREE_ID]: initial.groupsByWorktree[WORKTREE_ID]!.map((group) => + group.id === PRIMARY_GROUP ? { ...group, activeTabId: TERMINAL_ID } : group + ) + } + } + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'structured:epoch-1', + snapshotVersion: 1, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session' as const, + tabGroups: [ + { id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] }, + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session' as const, + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex' as const, + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + recordWebSessionFocusIntent( + { environmentId: 'local-structured-session' }, + WORKTREE_ID, + 'agent-session:codex-1', + undefined, + TERMINAL_ID + ) + const patch = applyWebSessionTabsSnapshot( + state, + snapshot, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...state, ...patch } as WebSessionTabsSyncState + + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(STRUCTURED_ID) + expect(applied.activeTabTypeByWorktree[WORKTREE_ID]).toBe('agent-session') + expect(applied.groupsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: SECONDARY_GROUP, activeTabId: STRUCTURED_ID }) + ]) + ) + }) + + it('rejects a reordered list reply after a newer subscription frame', () => { + const stale = structuredInventory('epoch-a', 7, 'stale-session') + const fresh = structuredInventory('epoch-a', 8, 'fresh-session') + const afterStale = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [stale]) + const afterFresh = applyLocalStructuredSessionTabSnapshots(afterStale, [fresh]) + const afterReorderedList = applyLocalStructuredSessionTabSnapshots(afterFresh, [stale]) + + expect(afterReorderedList).toBe(afterFresh) + expect(afterReorderedList.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) + ) + expect(afterReorderedList.unifiedTabsByWorktree[WORKTREE_ID]).not.toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'stale-session' })]) + ) + }) + + it('rejects same-version replay but accepts a new owner epoch', () => { + const first = structuredInventory('epoch-a', 8, 'session-a') + const afterFirst = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [first]) + const replayed = applyLocalStructuredSessionTabSnapshots(afterFirst, [first]) + const restarted = applyLocalStructuredSessionTabSnapshots(replayed, [ + structuredInventory('epoch-b', 1, 'session-b') + ]) + + expect(replayed).toBe(afterFirst) + expect(restarted).not.toBe(replayed) + expect(restarted.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'session-b' })]) + ) + }) +}) + +function structuredInventory( + publicationEpoch: string, + snapshotVersion: number, + sessionId: string +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch, + snapshotVersion, + activeGroupId: SECONDARY_GROUP, + activeTabId: `agent-session:${sessionId}`, + activeTabType: 'agent-session', + tabGroups: [ + { + id: SECONDARY_GROUP, + activeTabId: `agent-session:${sessionId}`, + tabOrder: [`agent-session:${sessionId}`] + } + ], + tabs: [ + { + type: 'agent-session', + id: `agent-session:${sessionId}`, + title: 'Codex Chat', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts new file mode 100644 index 00000000000..7d8ee6aa626 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -0,0 +1,184 @@ +import { useEffect } from 'react' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { useAppStore } from '../store' +import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' +import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch, + decideWebSessionTabsSnapshot +} from './web-session-tabs-sync' +import type { WebSessionTabsSyncState } from './web-session-tabs-sync' + +export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' +let localStructuredSessionTabsRestorePromise: Promise | null = null + +type SessionTabsEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { type: 'end' } + +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { + const structuredIds = new Set( + snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + ) + const visibleHostTabIds = structuredIds + const visibleIds = structuredIds + let projectedTabGroups = snapshot.tabGroups + ?.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), + activeTabId: + group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, + recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) + })) + .filter((group) => group.tabOrder.length > 0) + + return { + ...snapshot, + activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, + activeTabType: + snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, + activeGroupId: + snapshot.activeGroupId && + projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) + ? snapshot.activeGroupId + : (projectedTabGroups?.[0]?.id ?? null), + tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), + tabGroups: projectedTabGroups, + // Why: group membership locates chats; the renderer's split tree remains locally authoritative. + tabGroupLayout: undefined + } +} + +export function applyStructuredSessionTabSnapshots( + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER +): void { + const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( + (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), + { frames: [] } + ) + settleStructuredSessionMirror() +} + +export function applyLocalStructuredSessionTabSnapshots< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER, + now = Date.now() +): State { + let next = state + for (const snapshot of snapshots) { + // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. + if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { + continue + } + if (!decideWebSessionTabsSnapshot(snapshot, owner).apply) { + continue + } + const patch = applyWebSessionTabsSnapshot( + next, + projectLocalStructuredSessionTabs(snapshot), + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + } + return next +} + +export function restoreLocalStructuredSessionTabsOnce(): Promise { + localStructuredSessionTabsRestorePromise ??= refreshLocalStructuredSessionTabs() + .then(() => undefined) + .catch((error) => { + localStructuredSessionTabsRestorePromise = null + throw error + }) + return localStructuredSessionTabsRestorePromise +} + +/** Fetch the current host inventory even after the startup restore has settled. */ +export function refreshLocalStructuredSessionTabs(): Promise { + return window.api.runtime + .call({ method: 'session.tabs.listAll', params: {} }) + .then((response) => { + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshots = result.snapshots ?? [] + applyStructuredSessionTabSnapshots(snapshots) + return snapshots + }) +} + +async function startLocalStructuredSessionTabsSync(args: { + isDisposed: () => boolean + setUnsubscribe: (unsubscribe: () => void) => void +}): Promise { + const status = await window.api.runtime.getStatus() + if (args.isDisposed()) { + return + } + const supported = status.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + await restoreLocalStructuredSessionTabsOnce() + if (args.isDisposed()) { + return + } + if (!supported) { + return + } + const handle = await window.api.runtime.subscribe( + { method: 'session.tabs.subscribeAll', params: {} }, + (response) => { + if (args.isDisposed() || !response.ok) { + return + } + const event = response.result as SessionTabsEvent + if (event.type === 'snapshots') { + applyStructuredSessionTabSnapshots(event.snapshots) + } else if (event.type === 'snapshot' || event.type === 'updated') { + applyStructuredSessionTabSnapshots([event]) + } + } + ) + if (args.isDisposed()) { + handle.unsubscribe() + } else { + args.setUnsubscribe(handle.unsubscribe) + } +} + +export function useLocalStructuredSessionTabsSync(): void { + const ready = useAppStore( + (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady + ) + useEffect(() => { + if (!ready) { + return + } + let disposed = false + let unsubscribe = (): void => {} + void startLocalStructuredSessionTabsSync({ + isDisposed: () => disposed, + setUnsubscribe: (next) => { + unsubscribe = next + } + }).catch((error) => console.warn('[structured-session-tabs] sync failed', error)) + return () => { + disposed = true + unsubscribe() + } + }, [ready]) +} diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts new file mode 100644 index 00000000000..799be15668d --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + subscribe: vi.fn() +})) + +vi.mock('./runtime-environment-revision', () => ({ + getRuntimeEnvironmentRevision: () => 7 +})) + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +import { subscribeStructuredAgentSession } from './structured-agent-session-client' + +describe('subscribeStructuredAgentSession', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + Object.assign(window, { + api: { + runtimeEnvironments: { subscribe: mocks.subscribe } + } + }) + }) + + it('forwards graceful remote closes to the reconnect owner', async () => { + const onClose = vi.fn() + + await subscribeStructuredAgentSession( + { kind: 'environment', environmentId: 'env-1' }, + { sessionId: 'session-1' }, + vi.fn(), + vi.fn(), + onClose + ) + + const callbacks = mocks.subscribe.mock.calls[0]?.[1] as { onClose?: () => void } + expect(callbacks.onClose).toBe(onClose) + callbacks.onClose?.() + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts new file mode 100644 index 00000000000..0e8d2ce16f2 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -0,0 +1,41 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client' + +export function callStructuredAgentSession( + target: RuntimeClientTarget, + method: string, + params?: unknown +): Promise { + return callRuntimeRpc(target, method, params) +} + +export async function subscribeStructuredAgentSession( + target: RuntimeClientTarget, + params: unknown, + onEvent: (event: AgentSessionSubscribeEvent) => void, + onError: (error: unknown) => void, + onClose: () => void +): Promise<{ unsubscribe: () => void }> { + const onResponse = (response: RuntimeRpcResponse): void => { + if (!response.ok) { + onError(response.error) + return + } + onEvent(response.result as AgentSessionSubscribeEvent) + } + if (target.kind === 'local') { + return window.api.runtime.subscribe({ method: 'agentSession.subscribe', params }, onResponse) + } + return window.api.runtimeEnvironments.subscribe( + { + selector: target.environmentId, + method: 'agentSession.subscribe', + params, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: getRuntimeEnvironmentRevision(target.environmentId) + }, + { onResponse, onError, onClose } + ) +} diff --git a/src/renderer/src/runtime/structured-agent-session-close.test.ts b/src/renderer/src/runtime/structured-agent-session-close.test.ts new file mode 100644 index 00000000000..2f545d41570 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-close.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + supportsCapability: vi.fn() +})) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +vi.mock('./structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { closeStructuredAgentSession } from './structured-agent-session-close' + +beforeEach(() => { + vi.clearAllMocks() + mocks.call.mockResolvedValue({ ok: true }) + mocks.supportsCapability.mockResolvedValue(true) +}) + +describe('closeStructuredAgentSession', () => { + it('closes a local session without a redundant capability probe', async () => { + await expect(closeStructuredAgentSession({ kind: 'local' }, 'codex-session-1')).resolves.toBe( + 'closed' + ) + + expect(mocks.supportsCapability).not.toHaveBeenCalled() + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.close', { + sessionId: 'codex-session-1' + }) + }) + + it('closes through a paired host that advertises the structured session surface', async () => { + const target = { kind: 'environment', environmentId: 'env-1' } as const + + await expect(closeStructuredAgentSession(target, 'claude-session-1')).resolves.toBe('closed') + + expect(mocks.call).toHaveBeenCalledWith(target, 'agentSession.close', { + sessionId: 'claude-session-1' + }) + }) + + it('does not send an unknown method to a legacy paired host', async () => { + mocks.supportsCapability.mockResolvedValue(false) + + await expect( + closeStructuredAgentSession( + { kind: 'environment', environmentId: 'legacy-env' }, + 'codex-session-1' + ) + ).resolves.toBe('unsupported') + + expect(mocks.call).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-close.ts b/src/renderer/src/runtime/structured-agent-session-close.ts new file mode 100644 index 00000000000..d11094379dd --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-close.ts @@ -0,0 +1,23 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget +} from './runtime-rpc-client' +import { callStructuredAgentSession } from './structured-agent-session-client' + +export async function closeStructuredAgentSession( + target: RuntimeClientTarget, + sessionId: string +): Promise<'closed' | 'unsupported'> { + if ( + target.kind === 'environment' && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + )) + ) { + return 'unsupported' + } + await callStructuredAgentSession(target, 'agentSession.close', { sessionId }) + return 'closed' +} diff --git a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts index 393e64b0621..1df1f4df697 100644 --- a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts +++ b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts @@ -144,7 +144,14 @@ export function reconcileClientOwnedTabPlacement( if (working.has(requestedGroupId) || layoutGroupIds.has(requestedGroupId)) { return requestedGroupId } - return activeGroupIdIfValid ?? groupOrder[0] + // Why: with no local group to join, repair the rendered leaf first; failing that, + // materialize the requested group so a tab is never published into an unrendered one. + return ( + activeGroupIdIfValid ?? + groupOrder[0] ?? + layoutGroupIds.values().next().value ?? + requestedGroupId + ) } const movedTabIds = new Set() diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index 5fd06192f40..a23013f46ce 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -82,6 +82,11 @@ export function resolveWebSessionVisibleTabId( const tabId = state.activeTabIdByWorktree?.[worktreeId] return tabId && tabs.some((tab) => tab.id === tabId) ? tabId : null } + // Why: a structured chat tab has no per-worktree active-entity map to address it by, so the + // entityId lookup below would always miss. There is at most one per worktree here. + if (currentType === 'agent-session') { + return tabs.find((tab) => tab.contentType === 'agent-session')?.id ?? null + } const entityId = currentType === 'browser' ? state.activeBrowserTabIdByWorktree?.[worktreeId] diff --git a/src/renderer/src/runtime/web-session-intent-owner.test.ts b/src/renderer/src/runtime/web-session-intent-owner.test.ts index e86ada791b1..137b89ed991 100644 --- a/src/renderer/src/runtime/web-session-intent-owner.test.ts +++ b/src/renderer/src/runtime/web-session-intent-owner.test.ts @@ -6,6 +6,7 @@ import { } from './web-session-close-intent' import { peekWebSessionFocusIntent, + clearWebSessionFocusIntentIfMatches, recordWebSessionFocusIntent, resetWebSessionFocusIntentForTests } from './web-session-focus-intent' @@ -47,6 +48,16 @@ describe('web session intent ownership', () => { expect(peekWebSessionFocusIntent(OWNER_B, WORKTREE_ID)).toBeNull() }) + it('does not let an older failed create clear a newer focus intent', () => { + recordWebSessionFocusIntent(OWNER_A, WORKTREE_ID, 'agent-session:newer') + + clearWebSessionFocusIntentIfMatches(OWNER_A, WORKTREE_ID, 'agent-session:older') + + expect(peekWebSessionFocusIntent(OWNER_A, WORKTREE_ID)).toEqual({ + hostTabId: 'agent-session:newer' + }) + }) + it('isolates reorder intents across runtimes and same-id re-pairs', () => { recordWebSessionReorderIntent(OWNER_A, WORKTREE_ID, 'group-1', ['tab-b', 'tab-a'], 1_000) diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts new file mode 100644 index 00000000000..cde8bb66991 --- /dev/null +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::/worktree' +const GROUP_ID = 'group-1' + +function structuredTab(sessionId: string, sortOrder: number): Tab { + return { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1, + isPinned: false + } +} + +describe('web session structured tab focus', () => { + it('keeps the exact active structured tab across a host snapshot', () => { + const first = structuredTab('session-1', 0) + const second = structuredTab('session-2', 1) + const state = { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: second.id, + tabOrder: [first.id, second.id] + } + ] + }, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } as WebSessionTabsSyncState + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: GROUP_ID, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: GROUP_ID, + activeTabId: 'agent-session:session-1', + tabOrder: ['agent-session:session-1', 'agent-session:session-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-2', + title: 'Codex Chat', + sessionId: 'session-2', + agent: 'codex', + isActive: false + } + ] + } + + const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) + + const applied = patch === state ? state : ({ ...state, ...patch } as WebSessionTabsSyncState) + + expect(applied.groupsByWorktree[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + expect(applied.unifiedTabsByWorktree[WORKTREE_ID]).toBe( + state.unifiedTabsByWorktree[WORKTREE_ID] + ) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index e6ddab72906..9e3d53751f6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -14,6 +14,7 @@ import { acceptReplayedWebSessionTabsSnapshot, applyFreshWebSessionTabsSnapshot, applyWebSessionTabsSnapshot, + resolveHostSessionTabIdForWebSessionTab, shouldApplyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' @@ -41,6 +42,91 @@ vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ describe('applyWebSessionTabsSnapshot', () => { beforeEach(resetWebSessionTabsSyncTestState) + it('projects structured agent sessions as native unified tabs', () => { + const agentTab = { + type: 'agent-session' as const, + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex' as const, + isActive: true + } + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([agentTab], { + activeTabId: agentTab.id, + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: agentTab.id, + tabOrder: [agentTab.id] + } + ] + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toEqual([ + expect.objectContaining({ + id: 'structured-agent-session-session-1', + entityId: 'session-1', + contentType: 'agent-session', + agentSessionAgent: 'codex' + }) + ]) + expect(patch.activeTabTypeByWorktree?.[WT]).toBe('agent-session') + expect( + resolveHostSessionTabIdForWebSessionTab( + { ...makeState(), ...patch }, + { environmentId: ENV, worktreeId: WT, tabId: 'structured-agent-session-session-1' } + ) + ).toBe(agentTab.id) + }) + + it('removes a restored structured tab when the host publishes no structured sessions', () => { + const structuredTab: Tab = { + id: 'structured-agent-session-session-1', + entityId: 'session-1', + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + const patch = applyWebSessionTabsSnapshot( + makeState({ + activeTabId: structuredTab.id, + activeTabIdByWorktree: { [WT]: structuredTab.id }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WT]: 'agent-session' }, + unifiedTabsByWorktree: { [WT]: [structuredTab] }, + tabBarOrderByWorktree: { [WT]: [structuredTab.id] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: structuredTab.id, + tabOrder: [structuredTab.id] + } + ] + } + }), + makeSnapshot([], { activeTabType: null }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toBeUndefined() + expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal') + }) + it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => { const state = makeState() const newer = makeSnapshot([], { snapshotVersion: 3, activeTabType: null }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 073263be99a..a213b2dbfe3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -15,6 +15,7 @@ import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-fiel import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionFileTab, @@ -96,6 +97,7 @@ import { shouldSkipWebRuntimeWakeTerminalRespawn } from './web-runtime-wake-terminal-respawn' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' import { @@ -166,6 +168,12 @@ type SessionTabsRemovalFence = { pendingCount: number } +export type WebSessionTabsSnapshotApplyOptions = { + contentScope?: 'all' | 'agent-session' + preserveLocalLayout?: boolean + terminalPtyMode?: 'local' | 'remote' +} + type TrackedWebSessionTabsWorktree = { worktree: string freshness: SnapshotFreshness @@ -211,6 +219,11 @@ type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'r type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab +type MirroredAgentTab = { + hostTabId: string + unifiedTab: Tab +} + type MirroredTerminalTab = { tab: TerminalTab hostTabId: string @@ -957,6 +970,45 @@ function isReadyEditorTab( return tab.type === 'markdown' || tab.type === 'file' } +function isAgentSessionTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is RuntimeMobileSessionAgentTab { + return tab.type === 'agent-session' +} + +function buildMirroredAgentTabs( + snapshot: RuntimeMobileSessionTabsResult, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + currentUnifiedTabs: readonly Tab[], + now: number +): MirroredAgentTab[] { + return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { + const localId = structuredAgentSessionTabId(tab.sessionId) + const existing = currentUnifiedTabs.find( + (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId + ) + return { + hostTabId: tab.id, + unifiedTab: { + id: localId, + entityId: tab.sessionId, + groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, + worktreeId: snapshot.worktree, + contentType: 'agent-session', + agentSessionAgent: tab.agent, + label: tab.title.trim() || 'Codex Chat', + customLabel: null, + color: tab.color !== undefined ? tab.color : (existing?.color ?? null), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + sortOffset + index, + isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true + } + } + }) +} + function localEditorFileId(tab: ReadyEditorSurface): string { if (tab.type === 'markdown' && tab.mode === 'markdown-preview') { return `markdown-preview::${tab.sourceFilePath}` @@ -1074,7 +1126,8 @@ function buildMirroredTerminalTabs( existingLayoutsByTabId: Readonly>, sortOffset: number, now: number, - focusTarget?: { parentTabId: string; leafId: string } + focusTarget?: { parentTabId: string; leafId: string }, + terminalPtyMode: 'local' | 'remote' = 'remote' ): MirroredTerminalTab[] { const groups = new Map() for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { @@ -1097,10 +1150,12 @@ function buildMirroredTerminalTabs( : undefined) ?? surfaces.find((surface) => surface.isActive) ?? surfaces[0]! + const ptyIdForSurface = (handle: string): string => + terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) const ptyIdsByLeafId = Object.fromEntries( surfaces .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') - .map((surface) => [surface.leafId, toRemoteRuntimePtyId(surface.terminal, environmentId)]) + .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) ) const layout = normalizeTerminalLayoutPtyOwnership( chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) @@ -2047,12 +2102,14 @@ function buildHostToLocalTabIdMap({ terminalSurfaces, terminalTabs, browserTabs, - editorTabs + editorTabs, + agentTabs }: { terminalSurfaces: readonly TerminalSurface[] terminalTabs: readonly TerminalTab[] browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] }): Map { const hostToLocal = new Map() const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) @@ -2070,6 +2127,9 @@ function buildHostToLocalTabIdMap({ for (const entry of editorTabs) { hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) } + for (const entry of agentTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } return hostToLocal } @@ -2080,6 +2140,7 @@ function updateHostSessionTabIdMappings(args: { terminalTabs: readonly TerminalTab[] browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] }): void { clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) @@ -2096,6 +2157,12 @@ function updateHostSessionTabIdMappings(args: { for (const entry of args.editorTabs) { setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } + for (const entry of args.agentTabs) { + hostSessionTabIdByLocalKey.set( + hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), + entry.hostTabId + ) + } } function retainClientPlacedMirroredTabs(args: { @@ -2602,6 +2669,7 @@ function tabEqual(a: Tab, b: Tab): boolean { a.worktreeId === b.worktreeId && a.executionHostId === b.executionHostId && a.contentType === b.contentType && + a.agentSessionAgent === b.agentSessionAgent && a.label === b.label && // Why: the generated label is the visible tab title; ignoring it let the // equality bail keep a unified tab that disagreed with its terminal tab. @@ -2647,6 +2715,9 @@ function sameGroups(a: readonly TabGroup[] | undefined, b: readonly TabGroup[] | } function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] { + if (tab.contentType === 'agent-session') { + return 'agent-session' + } if (tab.contentType === 'browser' || tab.contentType === 'terminal') { return tab.contentType } @@ -2658,7 +2729,8 @@ function applyWebSessionTabsSnapshotWithContext( rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, now = Date.now(), - batchContext?: WebSessionTabsBatchContext + batchContext?: WebSessionTabsBatchContext, + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { if (suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot)) { return state @@ -2730,7 +2802,10 @@ function applyWebSessionTabsSnapshotWithContext( } const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) - const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab) + const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' + const terminalSurfaceTabs = reconcilesNonAgentTabs + ? snapshot.tabs.filter(isTerminalSurfaceTab) + : [] const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) const nextRemotePtyIds = new Set( readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) @@ -2763,16 +2838,18 @@ function applyWebSessionTabsSnapshotWithContext( } } const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) - const retainedTerminalTabs = currentTerminalTabs.filter( - (tab) => - !shouldReplaceTerminalTab( - tab, - environmentId, - nextRemotePtyIds, - nextMirroredTerminalIds, - exactProvisionalHandoffs + const retainedTerminalTabs = reconcilesNonAgentTabs + ? currentTerminalTabs.filter( + (tab) => + !shouldReplaceTerminalTab( + tab, + environmentId, + nextRemotePtyIds, + nextMirroredTerminalIds, + exactProvisionalHandoffs + ) ) - ) + : currentTerminalTabs const mirroredTerminalTabs = buildMirroredTerminalTabs( snapshot, environmentId, @@ -2785,7 +2862,8 @@ function applyWebSessionTabsSnapshotWithContext( parentTabId: callerFocusIntentTab.parentTabId, leafId: callerFocusIntentTab.leafId } - : undefined + : undefined, + options?.terminalPtyMode ) const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) @@ -2806,10 +2884,9 @@ function applyWebSessionTabsSnapshotWithContext( const targetGroupId = chooseTargetGroupId(state, snapshot) const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) - const existingTabIndex = buildWebSessionExistingTabIndex({ - unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? [] - }) - const readyBrowserTabs = snapshot.tabs.filter(isReadyBrowserTab) + const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) + const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) const mirroredBrowserTabs = buildMirroredBrowserTabs( snapshot, @@ -2825,7 +2902,7 @@ function applyWebSessionTabsSnapshotWithContext( ) const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] const removedBrowserWorkspaceIds = new Set( - currentBrowserTabs + (reconcilesNonAgentTabs ? currentBrowserTabs : []) .filter((tab) => { if (mirroredBrowserWorkspaceIds.has(tab.id)) { return true @@ -2876,7 +2953,7 @@ function applyWebSessionTabsSnapshotWithContext( retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] : null - const readyEditorTabs = snapshot.tabs.filter(isReadyEditorTab) + const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) const mirroredEditorTabs = buildMirroredEditorTabs( snapshot, @@ -2888,10 +2965,18 @@ function applyWebSessionTabsSnapshotWithContext( mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, now ) + const mirroredAgentTabs = buildMirroredAgentTabs( + snapshot, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, + currentUnifiedTabs, + now + ) const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) const removedEditorFileIds = new Set( - worktreeOpenFiles + (reconcilesNonAgentTabs ? worktreeOpenFiles : []) .filter( (file) => file.runtimeEnvironmentId === environmentId && @@ -2933,8 +3018,10 @@ function applyWebSessionTabsSnapshotWithContext( return sameOpenFiles(state.openFiles, next) ? state.openFiles : next })() advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) - const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { + if (tab.contentType === 'agent-session') { + return false + } if (tab.contentType === 'browser') { return ( !removedBrowserWorkspaceIds.has(tab.entityId) && @@ -2971,10 +3058,12 @@ function applyWebSessionTabsSnapshotWithContext( ) const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) + const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) const mirroredUnifiedTabs = [ ...mirroredTerminalUnifiedTabs, ...mirroredBrowserUnifiedTabs, - ...mirroredEditorUnifiedTabs + ...mirroredEditorUnifiedTabs, + ...mirroredAgentUnifiedTabs ] const nextUnifiedTabs = retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 @@ -3012,6 +3101,14 @@ function applyWebSessionTabsSnapshotWithContext( : null const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null + const activeHostAgent = + snapshot.tabs + .filter(isAgentSessionTab) + .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null + const activeMirroredAgentTabId = activeHostAgent + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? + null) + : null const intentMirroredTerminalId = navigationIntentTab?.type === 'terminal' ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) @@ -3028,6 +3125,10 @@ function applyWebSessionTabsSnapshotWithContext( navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) : null + const intentMirroredAgent = + navigationIntentTab?.type === 'agent-session' + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null const currentActiveTerminalStillExists = state.activeTabIdByWorktree[worktreeId] && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) @@ -3081,6 +3182,13 @@ function applyWebSessionTabsSnapshotWithContext( worktreeId, nextUnifiedTabs ?? [] ) + const currentVisibleStructuredTabId = + currentVisibleUnifiedTabId && + nextUnifiedTabs?.find( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? currentVisibleUnifiedTabId + : null const activeGroupId = state.activeGroupIdByWorktree[worktreeId] // Why: Open Preview to the Side can activate an empty reserved group before the host // browser lands. A snapshot that still has the host terminal active must not treat @@ -3097,31 +3205,36 @@ function applyWebSessionTabsSnapshotWithContext( ? (intentMirroredBrowser?.unifiedTab.id ?? null) : navigationIntentTab?.type === 'terminal' ? intentTerminalId - : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (intentMirroredEditor?.unifiedTab.id ?? null) - : null + : navigationIntentTab?.type === 'agent-session' + ? (intentMirroredAgent?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (intentMirroredEditor?.unifiedTab.id ?? null) + : null : null const nextActiveUnifiedTabId = intentUnifiedTabId ?? currentVisibleUnifiedTabId ?? reservedEmptyPreviewFallbackTabId ?? - (snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserTabId ?? - mirroredBrowserTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorTabId ?? - mirroredEditorTabs[0]?.unifiedTab.id ?? + (snapshot.activeTabType === 'agent-session' + ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) + : snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserTabId ?? + mirroredBrowserTabs[0]?.unifiedTab.id ?? state.activeTabIdByWorktree[worktreeId] ?? nextActiveTerminalId) - : nextActiveTerminalId) + : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorTabId ?? + mirroredEditorTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : nextActiveTerminalId) const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) const hostToLocalTabId = buildHostToLocalTabIdMap({ terminalSurfaces: terminalSurfaceTabs, terminalTabs: mirroredTerminalTabEntries, browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs }) updateHostSessionTabIdMappings({ environmentId, @@ -3129,7 +3242,8 @@ function applyWebSessionTabsSnapshotWithContext( terminalSurfaces: terminalSurfaceTabs, terminalTabs: mirroredTerminalTabEntries, browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs }) const currentGroups = state.groupsByWorktree[worktreeId] ?? [] @@ -3142,7 +3256,11 @@ function applyWebSessionTabsSnapshotWithContext( // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, // host actives, and host layout apply only on first adoption (no client groups yet). const clientOwnedPlacement = (() => { - if (currentGroups.length === 0 || !nextUnifiedTabs) { + // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement + // is client-owned even before any local group record exists — first adoption on an + // empty worktree must repair a rendered-leaf-without-record or materialize a rendered + // group instead of publishing the tab into a group no local leaf will ever show. + if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { return null } // Why: an entity-identical replacement (provisional terminal → mirrored surface, local @@ -3587,6 +3705,9 @@ function applyWebSessionTabsSnapshotWithContext( if (!nextGroups) { return state.layoutByWorktree } + // Why: client-owned placement derives its layout from the local one (pruned, plus + // repair leaves for surviving groups the layout lost), so a preserveLocalLayout owner + // still applies it — the option only rejects host-authored layout below. if (clientOwnedPlacement) { const clientLayout = clientOwnedPlacement.layout ?? @@ -3603,6 +3724,9 @@ function applyWebSessionTabsSnapshotWithContext( batchContext ) } + if (options?.preserveLocalLayout) { + return state.layoutByWorktree + } const validGroupIds = new Set(nextGroups.map((group) => group.id)) const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } @@ -3653,12 +3777,15 @@ function applyWebSessionTabsSnapshotWithContext( batchContext ) const nextActiveTabIdByWorktree = - (state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId + (state.activeTabIdByWorktree[worktreeId] ?? null) !== + (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) ? withWorktreeEntry( state, 'activeTabIdByWorktree', worktreeId, - nextActiveTerminalId, + intentMirroredAgent?.unifiedTab.id ?? + currentVisibleStructuredTabId ?? + nextActiveTerminalId, (current, next) => (current ?? null) === next, batchContext, false @@ -3690,32 +3817,44 @@ function applyWebSessionTabsSnapshotWithContext( : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId const focusIntentVisibleTabType = - navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId - ? ('browser' as const) - : navigationIntentTab?.type === 'terminal' && intentTerminalId - ? ('terminal' as const) - : intentEditorFileId - ? ('editor' as const) - : null + navigationIntentTab?.type === 'agent-session' && intentMirroredAgent + ? ('agent-session' as const) + : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId + ? ('browser' as const) + : navigationIntentTab?.type === 'terminal' && intentTerminalId + ? ('terminal' as const) + : intentEditorFileId + ? ('editor' as const) + : null const snapshotVisibleTabType = - snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId - ? ('browser' as const) - : snapshot.activeTabType === 'terminal' && nextActiveTerminalId - ? ('terminal' as const) - : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && - nextActiveEditorFileId - ? ('editor' as const) - : null + snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId + ? ('agent-session' as const) + : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId + ? ('browser' as const) + : snapshot.activeTabType === 'terminal' && nextActiveTerminalId + ? ('terminal' as const) + : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && + nextActiveEditorFileId + ? ('editor' as const) + : null const currentVisibleTabType = state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) const currentVisibleTabTypeStillValid = - currentVisibleTabType === 'browser' && currentActiveBrowserStillExists - ? ('browser' as const) - : currentVisibleTabType === 'editor' && currentActiveEditorStillExists - ? ('editor' as const) - : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists - ? ('terminal' as const) - : null + currentVisibleStructuredTabId !== null + ? ('agent-session' as const) + : currentVisibleTabType === 'agent-session' && + currentVisibleUnifiedTabId && + nextUnifiedTabs?.some( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? ('agent-session' as const) + : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists + ? ('browser' as const) + : currentVisibleTabType === 'editor' && currentActiveEditorStillExists + ? ('editor' as const) + : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists + ? ('terminal' as const) + : null const activeUnifiedTab = nextActiveUnifiedTabId && nextUnifiedTabs ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) @@ -3746,9 +3885,10 @@ function applyWebSessionTabsSnapshotWithContext( ? state.activeFileId : null const nextActiveTabId = isActiveWorktree - ? snapshot.activeTabType === 'terminal' - ? nextActiveTerminalId - : (currentActiveTerminalStillValid ?? nextActiveTerminalId) + ? (intentMirroredAgent?.unifiedTab.id ?? + (snapshot.activeTabType === 'terminal' + ? nextActiveTerminalId + : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) : state.activeTabId const nextActiveBrowserTabId = isActiveWorktree ? nextActiveBrowserWorkspaceId @@ -3873,9 +4013,17 @@ export function applyWebSessionTabsSnapshot( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { - return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) + return applyWebSessionTabsSnapshotWithContext( + state, + rawSnapshot, + environmentId, + now, + undefined, + options + ) } export function applyWebSessionTabsSnapshots( diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 98a76f17cc9..987a74a8341 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -26,7 +26,10 @@ function forceWorkspaceSessionReady(): void { workspaceSessionReady: true, pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} + pendingReconnectPtyIdByTabId: {}, + // Why: the activation gate waits on this flag; a degraded boot must still release it + // or no worktree ever gets its fallback terminal. + terminalStartupRestorationReady: true }) } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 4405e8041c8..f54798b089d 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -2,9 +2,9 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { + agentSubagentsEqual, AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, - agentSubagentsEqual, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, @@ -133,6 +133,7 @@ export type AgentStatusMetadata = { providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string + terminalResumeEligible?: false } export type AgentStatusUpdate = { @@ -599,7 +600,11 @@ function sleepingRecordFromEntry(args: { origin?: SleepingAgentSessionRecord['origin'] }): SleepingAgentSessionRecord | null { const agent = args.entry.agentType - if (!isResumableTuiAgent(agent) || !args.entry.providerSession) { + if ( + args.entry.terminalResumeEligible === false || + !isResumableTuiAgent(agent) || + !args.entry.providerSession + ) { return null } if (!getAgentResumeArgv(agent, args.entry.providerSession)) { @@ -2359,6 +2364,9 @@ export const createAgentStatusSlice: StateCreator { }) }) + it('keeps a structured session activation target during worktree reconciliation', () => { + const groupId = 'g-structured' + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + entityId: 'terminal-1', + groupId, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: 'structured-session-1', + entityId: 'session-1', + groupId, + worktreeId: WT, + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2, + agentSessionAgent: 'codex' + } + ] + }, + groupsByWorktree: { + [WT]: [ + { + id: groupId, + worktreeId: WT, + activeTabId: 'structured-session-1', + tabOrder: ['terminal-1', 'structured-session-1'] + } + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + tabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + ptyId: 'pty-1', + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'terminal-1': ['pty-1'] } + }) + + const result = store.getState().reconcileWorktreeTabModel(WT) + const state = store.getState() + + expect(result).toEqual({ + renderableTabCount: 2, + activeRenderableTabId: 'structured-session-1' + }) + expect(state.unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual([ + 'terminal-1', + 'structured-session-1' + ]) + expect(state.groupsByWorktree[WT][0]).toMatchObject({ + activeTabId: 'structured-session-1', + tabOrder: ['terminal-1', 'structured-session-1'] + }) + }) + it('collapses empty split groups when reconciliation drops a stale tab', () => { const terminalGroupId = 'g-terminal' const staleGroupId = 'g-stale' diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 5dad44150a6..1ef2684f3a0 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -712,7 +712,7 @@ export function projectWorktreeTabModelReconciliation( if (tab.contentType === 'browser') { return liveBrowserIds.has(tab.entityId) } - if (tab.contentType === 'simulator') { + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { return true } return liveEditorIds.has(tab.entityId) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1e1a4b3e699..c6c36b68fd0 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1,6 +1,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalSlice } from '../terminals/terminal-state' + +export type { TerminalSlice } from '../terminals/terminal-state' import { createTerminalEphemeralActions } from '../terminals/terminal-ephemeral-state' import { createTerminalTabCreationActions } from '../terminals/terminal-tab-creation' import { createActiveWorkspaceTerminalActions } from '../terminals/terminal-active-workspace-creation' @@ -47,6 +49,10 @@ export const createTerminalSlice: StateCreator nativeChatLaunchDraftByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, + terminalStartupRestorationReady: false, + setTerminalStartupRestorationReady: (value) => { + set({ terminalStartupRestorationReady: value }) + }, restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, closedTerminalTabTombstonesByTabId: {}, diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 3779ea88cc0..eaa996ad09a 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,3 +1,4 @@ +import type { TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -34,6 +35,7 @@ import type { } from './terminal-contracts' export type TerminalActions = { + setTerminalStartupRestorationReady: (value: boolean) => void setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void @@ -66,6 +68,12 @@ export type TerminalActions = { options?: { pendingActivationSpawn?: boolean initialPtyId?: string + /** Stable leaf identity for adopting an already-live pane without changing its pane key. */ + initialLeafId?: string + /** Published atomically with the tab so its first mount cannot spawn a bare shell. */ + pendingStartup?: TerminalState['pendingStartupByTabId'][string] + /** Published atomically with pendingStartup for automatic resume ownership. */ + automaticResumeClaim?: AutomaticAgentResumeClaim activate?: boolean recordInteraction?: boolean id?: string @@ -208,7 +216,10 @@ export type TerminalActions = { ) => void queueTabInitialCwd: (tabId: string, cwd: string) => void consumeTabInitialCwd: (tabId: string) => string | null - consumeTabStartupCommand: (tabId: string) => { + consumeTabStartupCommand: ( + tabId: string, + expected?: TerminalState['pendingStartupByTabId'][string] + ) => { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index fb7ebdabbe2..0f684e3f8b7 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -53,12 +53,17 @@ export function createTerminalStartupQueueActions( }) return pending }, - consumeTabStartupCommand: (tabId) => { + consumeTabStartupCommand: (tabId, expected) => { const pending = get().pendingStartupByTabId[tabId] - if (!pending) { + // Why identity, not equality: the one-shot settle must only spend the exact captured + // startup; a newer queued command for the same tab is someone else's to consume. + if (!pending || (expected && pending !== expected)) { return null } set((s) => { + if (s.pendingStartupByTabId[tabId] !== pending) { + return {} + } const next = { ...s.pendingStartupByTabId } delete next[tabId] return { pendingStartupByTabId: next } diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 4d5cbea8256..568cf2776cb 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -89,6 +89,8 @@ export type TerminalState = { tabBarOrderByWorktree: Record /** False until global reconnect publishes every deferred wake hint. */ workspaceSessionReady: boolean + /** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */ + terminalStartupRestorationReady: boolean restoredRuntimeHostIdByWorkspaceSessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 5f69aa19e74..11f9d1d2a59 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,6 +1,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' -import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds @@ -37,6 +38,19 @@ export function getNextTerminalOrdinal(tabs: TerminalTab[]): number { return nextOrdinal } +type TabStartupCommand = TerminalSlice['pendingStartupByTabId'][string] + +function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { + // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. + const launchToken = startup.launchConfig + ? (startup.launchToken ?? createBrowserUuid()) + : undefined + return { + ...startup, + ...(launchToken ? { launchToken } : {}) + } +} + export function createTerminalTabCreationActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -66,6 +80,15 @@ export function createTerminalTabCreationActions( ) } const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() + const requestedInitialLeafId = + options?.initialLeafId && isTerminalLeafId(options.initialLeafId) + ? options.initialLeafId + : undefined + // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. + const initialLeafId = + options?.initialPtyId || options?.pendingStartup + ? (requestedInitialLeafId ?? createBrowserUuid()) + : undefined const shouldActivate = options?.activate !== false const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` @@ -228,9 +251,23 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.ptyIdsByTabId, [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] }, + pendingStartupByTabId: options?.pendingStartup + ? { + ...orphanCleanupPatch.pendingStartupByTabId, + [tab.id]: normalizeTabStartupCommand(options.pendingStartup) + } + : orphanCleanupPatch.pendingStartupByTabId, + automaticAgentResumeClaimsByTabId: options?.automaticResumeClaim + ? { + ...orphanCleanupPatch.automaticAgentResumeClaimsByTabId, + [tab.id]: options.automaticResumeClaim + } + : orphanCleanupPatch.automaticAgentResumeClaimsByTabId, terminalLayoutsByTabId: { ...orphanCleanupPatch.terminalLayoutsByTabId, - [tab.id]: emptyLayoutSnapshot() + [tab.id]: initialLeafId + ? singlePaneLayoutSnapshot(initialLeafId, options?.initialPtyId) + : emptyLayoutSnapshot() } } }) diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index 68d9b80eb55..f182b8a7790 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -33,6 +33,7 @@ export function createWebAppApi(): Partial { // Staging already wrote through to browser storage, so there is nothing left to join. awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), + prepareTerminalStartupRestoration: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), diff --git a/src/renderer/src/web/preload-api/web-runtime-api.ts b/src/renderer/src/web/preload-api/web-runtime-api.ts index 47d300d09c0..a12e478ca45 100644 --- a/src/renderer/src/web/preload-api/web-runtime-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-api.ts @@ -1,6 +1,11 @@ import type { PreloadApi } from '../../../../preload/api-types' import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls' +import { + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + requireActiveEnvironment +} from './web-runtime-session' import { noopUnsubscribe } from './web-storage' export function createWebRuntimeApi(): NonNullable['runtime']> { @@ -8,6 +13,17 @@ export function createWebRuntimeApi(): NonNullable['runtime' syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), getStatus: () => getRemoteRuntimeStatus(), call: ({ method, params }) => callRuntimeEnvelope(method, params), + subscribe: async ({ method, params }, callback) => { + const environment = requireActiveEnvironment() + const subscription = await getClientForEnvironment(environment).subscribe(method, params, { + onResponse: callback + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + subscription.unsubscribe() + throw new Error('runtime_manually_disconnected') + } + return subscription + }, getTerminalFitOverrides: () => Promise.resolve([]), getTerminalDrivers: () => Promise.resolve([]), getBrowserDrivers: () => Promise.resolve([]), diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts new file mode 100644 index 00000000000..4fbb555ebf5 --- /dev/null +++ b/src/shared/agent-session-journal-item-key.ts @@ -0,0 +1,234 @@ +// Item-identity → stable journal key. Pure and shared: the host keys upserts +// with it and clients reconcile optimistic sends against the same string. +// +// Components are percent-encoded before joining so a value containing the +// delimiter cannot collide with a different identity. + +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +const KEY_DELIMITER = ':' +const VERBATIM_BOUNDED_COMPONENT_TAG = '%FF' +const BOUNDED_COMPONENT_PATTERN = /^[\s\S]{0,40}~orca-oversized~(?:[1-9]\d*)~[0-9a-f]{16}$/ +const PARSED_JOURNAL_ITEM_KEY = Symbol('parsedJournalItemKey') + +type ParsedJournalItemIdentity = AgentJournalItemIdentity & { + readonly [PARSED_JOURNAL_ITEM_KEY]?: string +} + +/** Longest raw component a key may embed. Real provider ids are tens of bytes; + * anything larger would push the composed key past wire page budgets, so it + * travels as a stable digest instead of verbatim. */ +export const MAX_JOURNAL_KEY_COMPONENT_CHARS = 1024 + +/** + * Deterministic stand-in for an oversized or ill-formed key component: same + * input, same output, so revisions and tombstones of one identity still share + * a key, and re-deriving from a parsed key is a fixed point (the bounded form + * is well-formed and far below the cap). The head keeps keys debuggable; + * length plus two independent hashes makes an accidental collision practically + * impossible. Pure JS because clients derive keys too and cannot reach + * node:crypto. + * + * JSON strings are arbitrary UTF-16 code units, so a component can carry a + * lone surrogate that `encodeURIComponent` throws on. Those values take the + * digest form too: the hashes run over the raw code units, so a value and its + * replacement-character spelling keep distinct keys. + */ +export function boundJournalKeyComponent(value: string): string { + if (value.length <= MAX_JOURNAL_KEY_COMPONENT_CHARS && !hasLoneSurrogate(value)) { + return value + } + const h1 = fnv1a32(value, 0x811c9dc5).toString(16).padStart(8, '0') + const h2 = fnv1a32(value, 0x0100_0193).toString(16).padStart(8, '0') + return `${wellFormedBoundedHead(value, 40)}~orca-oversized~${value.length}~${h1}${h2}` +} + +/** The diagnostic head must be valid Unicode for `encodeURIComponent`: a pair + * split by the cut is dropped and a lone surrogate becomes U+FFFD — the + * hashes over the raw units keep the full key collision-safe regardless. + * Digest forms are persisted, so for well-formed input the head must stay + * byte-stable across builds or one identity would stop sharing a key. */ +function wellFormedBoundedHead(value: string, maxUnits: number): string { + let head = '' + let index = 0 + while (index < value.length && index < maxUnits) { + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0 + if (next >= 0xdc00 && next <= 0xdfff) { + if (index + 1 >= maxUnits) { + break + } + head += value.charAt(index) + value.charAt(index + 1) + index += 2 + continue + } + head += '�' + index += 1 + continue + } + head += unit >= 0xdc00 && unit <= 0xdfff ? '�' : value.charAt(index) + index += 1 + } + return head +} + +function hasLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0 + if (next < 0xdc00 || next > 0xdfff) { + return true + } + index += 1 + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true + } + } + return false +} + +function fnv1a32(value: string, seed: number): number { + let hash = seed >>> 0 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x0100_0193) >>> 0 + } + return hash >>> 0 +} + +function encodePart(value: string | number): string { + const raw = String(value) + const bounded = boundJournalKeyComponent(raw) + const encoded = encodeURIComponent(bounded) + // `%FF` is not valid UTF-8 and cannot be emitted by encodeURIComponent. + return raw === bounded && isBoundedComponentRepresentation(raw) + ? `${VERBATIM_BOUNDED_COMPONENT_TAG}${encoded}` + : encoded +} + +function isBoundedComponentRepresentation(value: string): boolean { + return BOUNDED_COMPONENT_PATTERN.test(value) +} + +/** + * Stable string key for an item identity. + * + * Codex renumbers `item-N` ids on every resume, so its key is the thread, the + * turn, and the item's ordinal WITHIN that turn — a position that survives + * renumbering because a completed turn's item list does not change. `thread/fork` + * copies turns keeping their original turn ids, so the thread id must stay in the + * key. Claude copies item uuids on `--fork-session`, so its key is the session id + * plus the uuid. Text never participates. + */ +export function agentJournalItemKey(identity: AgentJournalItemIdentity): string { + // Parsed pre-tag digest keys must keep addressing their persisted revision chain. + const parsedKey = (identity as ParsedJournalItemIdentity)[PARSED_JOURNAL_ITEM_KEY] + if (parsedKey !== undefined) { + return parsedKey + } + if (identity.provider === 'codex') { + return [ + 'codex', + encodePart(identity.threadId), + encodePart(identity.turnId), + encodePart(identity.ordinal) + ].join(KEY_DELIMITER) + } + if (identity.provider === 'claude') { + return ['claude', encodePart(identity.sessionId), encodePart(identity.uuid)].join(KEY_DELIMITER) + } + if (identity.provider === 'orca') { + return ['orca', encodePart(identity.clientMessageId)].join(KEY_DELIMITER) + } + return [ + 'legacy', + encodePart(identity.agent), + encodePart(identity.sessionId), + encodePart(identity.recordId) + ].join(KEY_DELIMITER) +} + +/** Key for the pre-dispatch submission placeholder, before any provider echo. */ +export function agentJournalSubmissionKey(clientMessageId: string): string { + return agentJournalItemKey({ provider: 'orca', clientMessageId }) +} + +/** + * Inverse of {@link agentJournalItemKey}. Clients hold item KEYS, but an upsert + * needs the identity behind one — answering an approval re-appends the same + * item at the next revision. Components are percent-encoded; raw strings that + * imitate a bounded component carry a reserved encoded-domain tag. + */ +export function parseAgentJournalItemKey(key: string): AgentJournalItemIdentity | null { + // Persisted keys can be corrupted: a malformed percent sequence must fail + // the parse, never throw through journal replay or open. + const parts: string[] = [] + let preserveExactKey = false + for (const part of key.split(KEY_DELIMITER)) { + const decoded = decodePart(part) + if (!decoded) { + return null + } + parts.push(decoded.value) + preserveExactKey ||= decoded.tagged || isBoundedComponentRepresentation(decoded.value) + } + const [provider, ...rest] = parts + if (provider === 'codex' && rest.length === 3) { + const ordinal = Number(rest[2]) + return Number.isSafeInteger(ordinal) && ordinal >= 0 + ? parsedIdentity( + { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal }, + key, + preserveExactKey + ) + : null + } + if (provider === 'claude' && rest.length === 2) { + return parsedIdentity( + { provider, sessionId: rest[0] as string, uuid: rest[1] as string }, + key, + preserveExactKey + ) + } + if (provider === 'orca' && rest.length === 1) { + return parsedIdentity({ provider, clientMessageId: rest[0] as string }, key, preserveExactKey) + } + if (provider === 'legacy' && rest.length === 3) { + return parsedIdentity( + { + provider, + agent: rest[0] as string, + sessionId: rest[1] as string, + recordId: rest[2] as string + }, + key, + preserveExactKey + ) + } + return null +} + +function decodePart(part: string): { value: string; tagged: boolean } | null { + const tagged = part.startsWith(VERBATIM_BOUNDED_COMPONENT_TAG) + try { + const value = decodeURIComponent( + tagged ? part.slice(VERBATIM_BOUNDED_COMPONENT_TAG.length) : part + ) + return !tagged || isBoundedComponentRepresentation(value) ? { value, tagged } : null + } catch { + return null + } +} + +function parsedIdentity( + identity: T, + key: string, + preserveExactKey: boolean +): T { + if (preserveExactKey) { + Object.defineProperty(identity, PARSED_JOURNAL_ITEM_KEY, { value: key }) + } + return identity +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts new file mode 100644 index 00000000000..d855294bbc6 --- /dev/null +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody, + isAdmissibleAgentJournalRenderItem, + isAdmissibleAgentJournalSubmission +} from './agent-session-journal-schemas' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' + +const PAYLOAD = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true } +const RESOLUTION = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +// Canonical fixtures are typed: if a shape here stops compiling, the schema +// audit below is validating the wrong model. +const CANONICAL_BODIES: AgentJournalItemBody[] = [ + { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'hi', + providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD } + }, + { type: 'tool-call', name: 'Read', input: { path: 'a' } }, + { type: 'tool-result', output: 'ok', isError: false }, + { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' } + ] + }, + { kind: 'tool-call', name: 'Read', input: undefined, state: 'running' }, + { kind: 'tool-call', name: 'Read', input: {}, state: 'failed', output: PAYLOAD }, + { kind: 'diff', path: 'a.ts', patch: PAYLOAD }, + { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a', label: 'Yes' }], + resolution: RESOLUTION + }, + { + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + freeTextQuestionId: 'q-free', + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'client', resolvedAt: 5 } + }, + { kind: 'status', text: 'working' }, + { + kind: 'status', + text: 'turn', + turnLifecycle: { turnId: 'turn-1', state: 'running' }, + providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD } + } +] + +describe('canonical admission', () => { + it('admits every body shape this build writes', () => { + for (const body of CANONICAL_BODIES) { + expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) + } + }) + + it('admits a canonical render item and submission', () => { + const item: AgentJournalRenderItem = { + itemId: 'codex:t:turn:0', + revision: 1, + body: CANONICAL_BODIES[0] as AgentJournalItemBody, + sequence: 1, + observedAt: 1_000, + recovered: true + } + expect(isAdmissibleAgentJournalRenderItem(item)).toBe(true) + const submission: AgentJournalSubmission = { + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1_000, + resolvedAt: null + } + expect(isAdmissibleAgentJournalSubmission(submission)).toBe(true) + }) +}) + +describe('nested corruption is rejected', () => { + it('rejects prompt bodies whose options or resolution cannot be rendered', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'question', + question: 'Deploy?', + options: [], + resolution: null + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a' }], + resolution: RESOLUTION + }) + ).toBe(false) + }) + + it('rejects broken payload, lifecycle, and block shapes', () => { + expect( + isAdmissibleAgentJournalItemBody({ kind: 'diff', path: 'a.ts', patch: { head: 'x' } }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'x', turnLifecycle: true }) + ).toBe(false) + // A KNOWN block type with a broken payload must not slip through as a + // "future" block. + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: null }] + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'message', role: 'user', blocks: 'not-blocks' }) + ).toBe(false) + }) + + it('rejects shallow render items and submissions', () => { + expect( + isAdmissibleAgentJournalRenderItem({ + itemId: 'i-1', + revision: 1, + body: { kind: 'status', text: 'x' } + }) + ).toBe(false) + expect(isAdmissibleAgentJournalSubmission({ clientMessageId: 'm-1' })).toBe(false) + }) + + it('only admits message bodies for submissions', () => { + expect(isAdmissibleAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe(false) + expect(isAdmissibleAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe( + true + ) + }) +}) + +describe('forward tolerance', () => { + it('keeps unknown block types, wider state strings, and extra keys admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'narrator', + blocks: [{ type: 'future-block', data: 1 }], + futureField: 'ignored' + }) + ).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'tool-call', name: 'Read', state: 'paused' }) + ).toBe(true) + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'some-future-state', + providerItemId: null, + reason: null, + submittedAt: 1_000, + resolvedAt: null + }) + ).toBe(true) + }) +}) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts new file mode 100644 index 00000000000..2b1ab5404fc --- /dev/null +++ b/src/shared/agent-session-journal-schemas.ts @@ -0,0 +1,168 @@ +// ─── Canonical runtime schemas for the journal render model ───────────────── +// The journal admits JSON it did not just write — snapshot files and log rows +// re-enter from disk and are republished to clients — while the reducer, the +// shared projection, and the prompt surfaces dereference nested fields without +// guards. These schemas are the single deep validators for that render model: +// admission must reject a JSON-valid but structurally wrong item (a question +// whose `options` are null, a prompt without its `resolution`) so corruption +// lands in quarantine instead of throwing mid-render. +// +// Discriminants (`kind`, known block `type`s) are validated deeply. Open string +// fields (roles, dispatch/tool states) stay type-checked, never enum-checked, +// and unknown object keys pass — a same-version row written by a slightly +// newer build must not be misread as malformed (see journal-row-schema.ts). + +import { z } from 'zod' +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' + +const BoundedPayload = z.object({ + head: z.string(), + byteLength: z.number(), + digest: z.string(), + truncated: z.boolean() +}) + +const ProviderFrame = z.object({ + provider: z.string(), + kind: z.string(), + payload: BoundedPayload +}) + +const KNOWN_BLOCK_TYPES = new Set(['text', 'tool-call', 'tool-result', 'image-ref']) + +/** Renderers select blocks by `type` equality and skip what they cannot draw, + * so an unknown block type stays admissible; a known type with a broken + * payload does not. */ +const Block = z.union([ + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + text: z.string(), + providerFrame: ProviderFrame.optional() + }), + // `input: undefined` loses its key under JSON.stringify, so a persisted + // canonical tool call may lack it entirely. + z.object({ type: z.literal('tool-call'), name: z.string(), input: z.unknown().optional() }), + z.object({ + type: z.literal('tool-result'), + output: z.string(), + isError: z.boolean().optional() + }), + z.object({ + type: z.literal('image-ref'), + path: z.string().optional(), + url: z.string().optional(), + alt: z.string().optional() + }) + ]), + z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) +]) + +const PromptOption = z.object({ id: z.string(), label: z.string() }) + +const Resolution = z.object({ + state: z.string().min(1), + selectedOptionId: z.string().nullable(), + resolvedBy: z.string().nullable(), + resolvedAt: z.number().nullable() +}) + +const MessageBody = z.object({ + kind: z.literal('message'), + role: z.string().min(1), + blocks: z.array(Block) +}) + +export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ + MessageBody, + z.object({ + kind: z.literal('tool-call'), + name: z.string(), + // See the tool-call block: the key itself is lost when `input` is undefined. + input: z.unknown().optional(), + state: z.string().min(1), + output: BoundedPayload.optional() + }), + z.object({ kind: z.literal('diff'), path: z.string(), patch: BoundedPayload }), + z.object({ + kind: z.literal('approval'), + title: z.string(), + detail: z.string().nullable(), + options: z.array(PromptOption), + resolution: Resolution + }), + z.object({ + kind: z.literal('question'), + question: z.string(), + options: z.array(PromptOption), + freeTextQuestionId: z.string().optional(), + resolution: Resolution + }), + z.object({ + kind: z.literal('status'), + text: z.string(), + turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(), + providerFrame: ProviderFrame.optional() + }) +]) + +export const AgentJournalRenderItemSchema = z.object({ + itemId: z.string().min(1), + revision: z.number().int(), + body: AgentJournalItemBodySchema, + sequence: z.number().int(), + observedAt: z.number(), + recovered: z.literal(true).optional() +}) + +export const AgentJournalSubmissionSchema = z.object({ + clientMessageId: z.string().min(1), + fence: z.number().int(), + payloadFingerprint: z.string(), + dispatchState: z.string().min(1), + providerItemId: z.string().nullable(), + reason: z.string().nullable(), + submittedAt: z.number(), + resolvedAt: z.number().nullable() +}) + +export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { + return AgentJournalItemBodySchema.safeParse(value).success +} + +/** Submission rows may only carry a user-authored message body. */ +export function isAdmissibleAgentJournalMessageBody( + value: unknown +): value is AgentJournalMessageItem { + return MessageBody.safeParse(value).success +} + +export function isAdmissibleAgentJournalRenderItem( + value: unknown +): value is AgentJournalRenderItem { + return AgentJournalRenderItemSchema.safeParse(value).success +} + +export function isAdmissibleAgentJournalSubmission( + value: unknown +): value is AgentJournalSubmission { + return AgentJournalSubmissionSchema.safeParse(value).success +} + +/** Compile-time proof that every canonical value is admissible, so admission + * can never quarantine a row a writer in this build produced. The schemas are + * deliberately wider on open string fields, so only this direction holds. */ +type Admits = T +export type CanonicalJournalShapesAreAdmissible = [ + Admits ? true : false>, + Admits ? true : false>, + Admits< + AgentJournalRenderItem extends z.input ? true : false + >, + Admits ? true : false> +] diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts new file mode 100644 index 00000000000..17184f00349 --- /dev/null +++ b/src/shared/agent-session-journal-types.ts @@ -0,0 +1,216 @@ +// ─── Canonical agent-session journal: cross-process wire shapes ───────────── +// The host-owned timeline for a structured agent session. Everything here must +// be plain JSON: rows are persisted verbatim and later republished to clients, +// so no class instances, Maps, or Dates. +// +// Rows are append-only. `schemaVersion` is upcast at read time and never +// rewritten in place, so a host that cannot read a row refuses to write the +// journal rather than skipping or compacting past it. + +import type { AgentType } from './agent-status-types' +import type { NativeChatBlock, NativeChatRole } from './native-chat-types' + +export { type AgentType } + +/** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ +export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 1 + +/** Epoch-qualified position in one journal. `sequence` 0 means "before the first row". */ +export type AgentJournalCursor = { + epoch: string + sequence: number +} + +/** The durable provider session a journal is bound to. + * Codex is one thread id; Claude needs the leaf because concurrent resumes of + * one session id branch the same transcript. */ +export type AgentSessionProviderHandle = + | { kind: 'codex'; threadId: string } + | { kind: 'claude'; sessionId: string; leafUuid: string | null } + | { kind: 'opaque'; agent: AgentType; value: string } + +/** The narrow slice of the durable session record the journal needs. The full + * record (owner, lease, account home) belongs to the session store. */ +export type AgentSessionJournalIdentity = { + /** Orca agent-session id — the journal's primary key. */ + sessionId: string + /** Execution-host workspace key. Identical for a worktree, a folder + * workspace, a WSL distro, and an SSH host; never a path. */ + workspaceId: string + /** Execution host that owns the process, so a client restart adjudicates nothing. */ + hostId: string + agent: AgentType + providerHandle: AgentSessionProviderHandle +} + +// ─── Item identity ────────────────────────────────────────────────────────── +// Reconciliation keys, settled by the provider spikes. Codex renumbers items +// positionally on resume, so a persisted item id is never an identity. Claude +// copies the original uuids on fork, so the uuid is. + +export type AgentJournalItemIdentity = + | { provider: 'codex'; threadId: string; turnId: string; ordinal: number } + | { provider: 'claude'; sessionId: string; uuid: string } + /** A submission Orca minted before any provider echo existed. */ + | { provider: 'orca'; clientMessageId: string } + /** Bridge-era transcript record with no provider-stable identity. */ + | { provider: 'legacy'; agent: AgentType; sessionId: string; recordId: string } + +// ─── Bounded payloads ─────────────────────────────────────────────────────── + +/** A tool output or diff body clipped to a head plus a content-addressed + * remainder. Crossing a bound sets `truncated`; it never silently drops. */ +export type AgentJournalBoundedPayload = { + head: string + /** Byte length of the ORIGINAL payload, not of `head`. */ + byteLength: number + /** sha256 of the original payload, and the blob store key when `truncated`. */ + digest: string + truncated: boolean +} + +// ─── Render-model items ───────────────────────────────────────────────────── + +export type AgentJournalMessageItem = { + kind: 'message' + role: NativeChatRole + blocks: NativeChatBlock[] +} + +export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' + +export type AgentJournalToolCallItem = { + kind: 'tool-call' + name: string + input: unknown + state: AgentJournalToolCallState + output?: AgentJournalBoundedPayload +} + +export type AgentJournalDiffItem = { + kind: 'diff' + path: string + patch: AgentJournalBoundedPayload +} + +export const AGENT_JOURNAL_RESOLUTION_STATES = ['pending', 'resolved', 'cancelled'] as const +export type AgentJournalResolutionState = (typeof AGENT_JOURNAL_RESOLUTION_STATES)[number] + +/** Approvals and questions are durable items with explicit resolution state, so + * a second client answering one prompt loses the compare-and-set instead of + * invoking the provider callback twice. */ +export type AgentJournalResolution = { + state: AgentJournalResolutionState + /** Option id the winner picked; null while pending or cancelled. */ + selectedOptionId: string | null + /** Opaque client identity of the resolver, for "answered on ". */ + resolvedBy: string | null + resolvedAt: number | null +} + +export type AgentJournalPromptOption = { + id: string + label: string +} + +export type AgentJournalApprovalItem = { + kind: 'approval' + title: string + detail: string | null + options: AgentJournalPromptOption[] + resolution: AgentJournalResolution +} + +export type AgentJournalQuestionItem = { + kind: 'question' + question: string + options: AgentJournalPromptOption[] + /** Present when the provider accepts an answer outside the offered options. */ + freeTextQuestionId?: string + resolution: AgentJournalResolution +} + +export type AgentJournalStatusItem = { + kind: 'status' + text: string + /** Durable root-turn lifecycle used by clients to expose cancellation only + * while the provider can still accept it. */ + turnLifecycle?: { turnId: string; state: 'running' | 'completed' } + /** Additive fallback for provider traffic this host cannot model yet. Older + * clients still render `text`; newer clients expose the bounded frame. */ + providerFrame?: { + provider: string + kind: string + payload: AgentJournalBoundedPayload + } +} + +export type AgentJournalItemBody = + | AgentJournalMessageItem + | AgentJournalToolCallItem + | AgentJournalDiffItem + | AgentJournalApprovalItem + | AgentJournalQuestionItem + | AgentJournalStatusItem + +/** One reduced timeline entry. `sequence` orders the list; `observedAt` is the + * provider's own clock and may sort earlier than a later sequence when the row + * was recovered after a crash. */ +export type AgentJournalRenderItem = { + itemId: string + revision: number + body: AgentJournalItemBody + sequence: number + observedAt: number + /** Set when the row was appended by crash reconciliation rather than live. */ + recovered?: true +} + +// ─── Submissions ──────────────────────────────────────────────────────────── + +export const AGENT_JOURNAL_DISPATCH_STATES = ['pending', 'accepted', 'rejected', 'unknown'] as const +export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[number] + +/** The write-ahead submission row, projected. `unknown` is a displayed state: + * the turn reads as delivery unconfirmed, never as sent and never as failed. */ +export type AgentJournalSubmission = { + clientMessageId: string + fence: number + payloadFingerprint: string + dispatchState: AgentJournalDispatchState + /** Provider item identity adopted on accept; null otherwise. */ + providerItemId: string | null + /** Terminal reason on `rejected`. */ + reason: string | null + submittedAt: number + resolvedAt: number | null +} + +/** Durable answer to "did my send land?", keyed by client message id. Only an + * `accepted` dispatch mints one, and it outlives the journal tail. */ +export type AgentJournalAcceptanceReceipt = { + clientMessageId: string + providerItemId: string + cursor: AgentJournalCursor + acceptedAt: number +} + +// ─── Snapshots and cursor resume ──────────────────────────────────────────── + +export type AgentJournalSnapshot = { + sessionId: string + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} + +/** Why a cursor could not be resumed. Every value forces a clean snapshot + * reload on the client. */ +export const AGENT_JOURNAL_RESET_REASONS = [ + 'epoch_changed', + 'cursor_ahead', + 'cursor_compacted', + 'journal_gap', + 'schema_unreadable' +] as const +export type AgentJournalResetReason = (typeof AGENT_JOURNAL_RESET_REASONS)[number] diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts new file mode 100644 index 00000000000..dd8b5433efd --- /dev/null +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest' +import { + adjudicateAgentSessionRestart, + agentSessionLeaseAdmitsWriter, + classifyObservedAgentSessionSpawnToken, + evaluateAgentSessionAcquisition, + isProvenAliveProbe, + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' + +const OWNER = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-a' +} + +function lease(overrides: Partial = {}): AgentSessionLease { + return { + sessionId: 'session-alpha-1', + runtimeKind: 'native', + runtimeFence: 7, + handoffStage: null, + provenHandleLinkId: 'link-1', + ownerProcess: OWNER, + reservedSpawnToken: 'spawn-a', + leaseDeadlineAt: 1_000, + lastRenewedAt: 500, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null, + ...overrides + } +} + +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +function acquire( + leaseState: AgentSessionLease, + probe: AgentSessionOwnerProbe, + handoffOperationId: string | null = null +) { + return evaluateAgentSessionAcquisition({ + lease: leaseState, + expectedFence: leaseState.runtimeFence, + handoffOperationId, + probe + }) +} + +describe('proof classification', () => { + it('treats a pid match with nothing PID-reuse-safe as no proof at all', () => { + // A bare pid match is exactly the case that mints a second writer after pid reuse. + expect(isProvenAliveProbe({ outcome: 'identity-matched', matchedOn: [] })).toBe(false) + expect(isProvenAliveProbe(MATCHED)).toBe(true) + expect(isProvenDeadProbe(INDETERMINATE)).toBe(false) + expect(isProvenAliveProbe(INDETERMINATE)).toBe(false) + }) + + it.each([ + ['exit-observed', { outcome: 'exit-observed' } as AgentSessionOwnerProbe], + ['pid-absent', { outcome: 'pid-absent' } as AgentSessionOwnerProbe], + [ + 'identity-mismatch', + { outcome: 'identity-mismatch', field: 'spawn-token' } as AgentSessionOwnerProbe + ] + ])('accepts %s as proof of death', (_name, probe) => { + expect(isProvenDeadProbe(probe)).toBe(true) + }) + + it('never counts a reservation probe or an indeterminate answer as death', () => { + expect(isProvenDeadProbe({ outcome: 'reservation-unused' })).toBe(false) + expect(isProvenDeadProbe(INDETERMINATE)).toBe(false) + }) +}) + +describe('acquisition compare-and-swap', () => { + it('refuses a stale fence and grants at exactly fence + 1', () => { + const held = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null }) + expect( + evaluateAgentSessionAcquisition({ + lease: held, + expectedFence: held.runtimeFence - 1, + handoffOperationId: null, + probe: MATCHED + }) + ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) + }) + + it('refuses the loser of a concurrent swap: only one caller sees the pre-state fence', () => { + const before = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null }) + const winner = acquire(before, MATCHED) + expect(winner).toEqual({ decision: 'granted', nextFence: 8 }) + // The loser still holds the pre-swap fence, which is no longer current. + const after = lease({ ...before, runtimeFence: 8, claimStatus: 'reserved' }) + expect( + evaluateAgentSessionAcquisition({ + lease: after, + expectedFence: 7, + handoffOperationId: null, + probe: MATCHED + }) + ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + }) + + it('never grants a second owner on expiry alone', () => { + // The recorded owner is long past its deadline; nothing here may consult that deadline. + const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 }) + expect(acquire(expired, INDETERMINATE)).toEqual({ + decision: 'refused', + code: 'agent_session_ownership_unknown' + }) + expect(acquire(expired, MATCHED)).toEqual({ + decision: 'refused', + code: 'agent_session_conflict' + }) + expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ + decision: 'granted', + nextFence: 8 + }) + }) + + it('refuses while unreconciled even with proof the owner is dead', () => { + expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ + decision: 'refused', + code: 'execution_owner_reconciling' + }) + }) + + it('keeps a conflicted claim conflicted regardless of proof', () => { + expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ + decision: 'refused', + code: 'agent_session_conflict' + }) + }) + + it.each([ + ['recovering', 'agent_session_ownership_unknown'], + ['manual-recovery', 'agent_session_ownership_unknown'], + ['preparing', 'agent_session_conflict'] + ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { + expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ + decision: 'refused', + code + }) + }) + + it.each(['old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses a different handoff operation and replays the matching one at %s', + (handoffStage) => { + const mid = lease({ + handoffStage, + handoffOperationId: 'op-1', + ownerProcess: null, + claimStatus: 'reserved' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ + decision: 'retry-reservation', + fence: 7 + }) + } + ) + + it('refuses a reservation whose spawn may have won the race with the crash', () => { + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) + expect(acquire(reserved, INDETERMINATE)).toEqual({ + decision: 'refused', + code: 'agent_session_ownership_unknown' + }) + expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ + decision: 'granted', + nextFence: 8 + }) + }) +}) + +describe('restart reconciliation', () => { + it('re-adopts a proven-live TUI owner without moving the fence', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ runtimeKind: 'tui' }), + probe: MATCHED, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'readopt' }) + }) + + it('routes a surviving native owner to recovery instead of readopting a dead transport', () => { + // The native child's stdio belonged to the runtime that died; readoption would extend + // a lease no process can drive. Recovery stops the orphan and respawns at fence + 1. + expect( + adjudicateAgentSessionRestart({ lease: lease(), probe: MATCHED, observedAt: 9_000 }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) + }) + + it('bumps the fence exactly once for a proven-dead owner and records the evidence', () => { + const result = adjudicateAgentSessionRestart({ + lease: lease(), + probe: { outcome: 'identity-mismatch', field: 'process-start-time' }, + observedAt: 9_000 + }) + expect(result).toEqual({ + disposition: 'evicted', + nextFence: 8, + evidence: { + kind: 'identity-mismatch', + detail: 'mismatched process-start-time', + observedAt: 9_000 + } + }) + }) + + it('keeps re-asking about an unverifiable owner instead of evicting it', () => { + // A recorded exact identity can still be probed later; manual recovery is reserved + // for leases that name no process at all. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ leaseDeadlineAt: 1 }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'recovering', stage: 'recovering', reason: 'no answer' }) + }) + + it('keeps a pre-restart conflict conflicted while its owner cannot be proven gone', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted' }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) + }) + + it('keeps a conflict conflicted when it names no process to prove anything about', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted', ownerProcess: null }), + probe: { outcome: 'pid-absent' }, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) + }) + + it('frees a conflict whose named owner is proven gone', () => { + // Why: the conflict protects one specific process. Once that process is proven gone there is + // no claimant left, and a conflict with no exit is a session nobody can ever open again. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted' }), + probe: { outcome: 'pid-absent' }, + observedAt: 9_000 + }) + ).toEqual({ + disposition: 'evicted', + nextFence: 8, + evidence: { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt: 9_000 } + }) + }) + + it('frees a lease that names neither an owner nor a reservation, without moving the fence', () => { + // Why: an evicted lease has no owner and no token, so a restart has nothing to probe. + // Calling that an unproven reservation re-latched every released record on every boot. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: 'recovering' + }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'free', reason: 'lease has no owner and no reservation' }) + }) + + it('does not infer an ownerless native reservation is unused from restart alone', () => { + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved' }) + expect( + adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) + ).toEqual({ + disposition: 'recovering', + stage: 'manual-recovery', + reason: 'reservation with no proven process' + }) + }) + + it('frees a TUI reservation only when a probe proves nothing ever spawned', () => { + // A TUI child lives in a terminal that outlives the runtime, so absence needs proof. + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', runtimeKind: 'tui' }) + expect( + adjudicateAgentSessionRestart({ + lease: reserved, + probe: { outcome: 'reservation-unused' }, + observedAt: 9_000 + }) + ).toMatchObject({ disposition: 'evicted', nextFence: 8 }) + expect( + adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) + ).toMatchObject({ disposition: 'recovering', stage: 'manual-recovery' }) + }) +}) + +describe('writer admission and orphan spawn tokens', () => { + it('admits a writer only when reconciled, settled, live, and holding a process', () => { + expect(agentSessionLeaseAdmitsWriter(lease())).toBe(true) + expect(agentSessionLeaseAdmitsWriter(lease({ unreconciled: true }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ handoffStage: 'new-owner-proving' }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ claimStatus: 'reserved' }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ ownerProcess: null }))).toBe(false) + }) + + it('calls a spawn token with no matching lease an orphan', () => { + const leases = [lease(), lease({ sessionId: 'session-beta-1', reservedSpawnToken: 'spawn-b' })] + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-a', leases })).toBe('owned') + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-b', leases })).toBe('owned') + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-z', leases })).toBe('orphan') + }) + + it('still recognises an owner whose reservation token was cleared after proving', () => { + const proved = lease({ reservedSpawnToken: null }) + expect( + classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-a', leases: [proved] }) + ).toBe('owned') + }) +}) diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts new file mode 100644 index 00000000000..6d9deb54818 --- /dev/null +++ b/src/shared/agent-session-lease-adjudication.ts @@ -0,0 +1,261 @@ +/** + * Single-writer lease adjudication. + * + * Every decision here fails closed: expiry alone never grants a second owner, an unverifiable + * process counts as possibly alive, and a stage that cannot prove an owner keeps re-asking — + * or, when it names no process at all, ends in manual recovery — rather than handing the + * session to the other runtime. This is the opposite polarity + * from daemon adoption checks, which fail open on a missing start time — a wrong answer there + * refuses an adoption, a wrong answer here creates two writers on one provider session. + */ + +import { nextAgentSessionFence } from './agent-session-next-fence' +import type { + AgentSessionDeathEvidence, + AgentSessionHandoffStage, + AgentSessionLease +} from './agent-session-record' + +export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' + +export type AgentSessionOwnerProbe = + /** Orca watched this exact process exit. */ + | { outcome: 'exit-observed' } + /** The recorded pid is not present on the host. */ + | { outcome: 'pid-absent' } + /** The pid is present but is a different process. */ + | { outcome: 'identity-mismatch'; field: AgentSessionIdentityMatchField | 'command-line' } + /** The pid is present and at least one identity element was verified. */ + | { outcome: 'identity-matched'; matchedOn: readonly AgentSessionIdentityMatchField[] } + /** No process carries the reserved spawn token and the provider saw no activity after it. */ + | { outcome: 'reservation-unused' } + /** The host could not answer — restricted container, no start time, no token echo. */ + | { outcome: 'indeterminate'; reason: string } + +export type AgentSessionLeaseRefusalCode = + | 'agent_session_checkpoint_stale' + | 'agent_session_conflict' + | 'agent_session_ownership_unknown' + | 'agent_session_operation_conflict' + | 'execution_owner_reconciling' + +export type AgentSessionAcquisitionDecision = + | { decision: 'granted'; nextFence: number } + /** The same handoff operation re-entering its own reservation; no new fence, no new spawn. */ + | { decision: 'retry-reservation'; fence: number } + | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } + +export type AgentSessionRestartAdjudication = + | { disposition: 'readopt' } + /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ + | { disposition: 'free'; reason: string } + | { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence } + | { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string } + | { disposition: 'conflicted'; reason: string } + +/** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */ +const STAGES_ADMITTING_NEW_OWNER: ReadonlySet = new Set([ + 'old-owner-stopped', + 'new-owner-proving' +]) + +export function isProvenDeadProbe(probe: AgentSessionOwnerProbe): boolean { + return ( + probe.outcome === 'exit-observed' || + probe.outcome === 'pid-absent' || + probe.outcome === 'identity-mismatch' + ) +} + +/** + * A matched pid is only proof of life when something PID-reuse-safe matched with it. A bare pid + * match on a host that can produce neither a start time nor a token echo is indeterminate. + */ +export function isProvenAliveProbe(probe: AgentSessionOwnerProbe): boolean { + return probe.outcome === 'identity-matched' && probe.matchedOn.length > 0 +} + +function deathEvidenceFor( + probe: AgentSessionOwnerProbe, + observedAt: number +): AgentSessionDeathEvidence | null { + if (probe.outcome === 'exit-observed') { + return { kind: 'exit-observed', detail: 'observed process exit', observedAt } + } + if (probe.outcome === 'pid-absent') { + return { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt } + } + if (probe.outcome === 'identity-mismatch') { + return { kind: 'identity-mismatch', detail: `mismatched ${probe.field}`, observedAt } + } + return null +} + +/** True when the recorded owner may write right now. Used by every mutating path in later parts. */ +export function agentSessionLeaseAdmitsWriter(lease: AgentSessionLease): boolean { + return ( + !lease.unreconciled && + lease.handoffStage === null && + lease.claimStatus === 'live' && + lease.ownerProcess !== null + ) +} + +export function isAgentSessionFenceCurrent(lease: AgentSessionLease, fence: number): boolean { + return Number.isSafeInteger(fence) && fence === lease.runtimeFence +} + +/** + * Compare-and-swap acquisition. `probe` describes what the host could prove about the recorded + * owner; it is only consulted when a recorded owner or an unused reservation stands in the way. + */ +export function evaluateAgentSessionAcquisition(args: { + lease: AgentSessionLease + expectedFence: number + handoffOperationId: string | null + probe: AgentSessionOwnerProbe +}): AgentSessionAcquisitionDecision { + const { lease, expectedFence, handoffOperationId, probe } = args + if (lease.unreconciled) { + return { decision: 'refused', code: 'execution_owner_reconciling' } + } + if (!isAgentSessionFenceCurrent(lease, expectedFence)) { + return { decision: 'refused', code: 'agent_session_checkpoint_stale' } + } + if (lease.claimStatus === 'conflicted') { + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { + // Why: no stage expires into an owner; recovery is resolved by proof or by the user. + return { decision: 'refused', code: 'agent_session_ownership_unknown' } + } + if (lease.handoffStage === 'preparing') { + // Why: the old owner is quiesced but alive and still authoritative. + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage !== null && !STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage)) { + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage !== null && lease.handoffOperationId !== null) { + if (handoffOperationId !== lease.handoffOperationId) { + // Why: the retry key is operation id + fence + stage; a different id is a different intent. + return { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if ( + lease.ownerProcess === null && + STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage) && + lease.claimStatus === 'reserved' && + lease.reservedSpawnToken !== null + ) { + // Why: an idempotent re-run of a reservation that already exists at this fence. + return { decision: 'retry-reservation', fence: lease.runtimeFence } + } + } + if (lease.ownerProcess !== null) { + if (!isProvenDeadProbe(probe)) { + // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child + // stopped editing files and spending tokens. + return { + decision: 'refused', + code: isProvenAliveProbe(probe) + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + } + } + return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } + } + if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { + // Why: a reservation with no proven process is not a free lease — the crash may have lost + // the race with the spawn rather than beaten it. + return { decision: 'refused', code: 'agent_session_ownership_unknown' } + } + return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } +} + +/** + * Host-restart reconciliation for one persisted lease. Every lease is unreconciled at load and + * grants no writer until this returns. + */ +export function adjudicateAgentSessionRestart(args: { + lease: AgentSessionLease + probe: AgentSessionOwnerProbe + observedAt: number +}): AgentSessionRestartAdjudication { + const { lease, probe, observedAt } = args + if (lease.claimStatus === 'conflicted') { + const conflictedOwnerDeath = + lease.ownerProcess === null ? null : deathEvidenceFor(probe, observedAt) + if (conflictedOwnerDeath) { + // Why: the conflict names one specific process. Present-time proof that THAT process is gone + // leaves no claimant to protect, and a conflict with no exit is a session the user can never + // open again. Without such proof the conflict still outlives the process that observed it. + return { + disposition: 'evicted', + nextFence: nextAgentSessionFence(lease), + evidence: conflictedOwnerDeath + } + } + return { disposition: 'conflicted', reason: 'claim conflicted before restart' } + } + if (lease.ownerProcess === null) { + if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') { + // Why: the spawn token is minted before the child and is the only thing a child could be + // carrying. With no owner and no token nothing can hold this lease, so it is already free — + // treating it as an unproven reservation is what re-latches every released record on restart. + return { disposition: 'free', reason: 'lease has no owner and no reservation' } + } + if (probe.outcome === 'reservation-unused') { + return { + disposition: 'evicted', + nextFence: nextAgentSessionFence(lease), + evidence: { kind: 'pid-absent', detail: 'reservation never spawned', observedAt } + } + } + return { + disposition: 'recovering', + stage: 'manual-recovery', + reason: 'reservation with no proven process' + } + } + if (isProvenAliveProbe(probe)) { + if (lease.runtimeKind === 'native') { + // Why: the surviving child's stdio died with the previous runtime, so readoption + // would renew a lease no host can drive. Recovery stops it and respawns at fence + 1. + return { + disposition: 'recovering', + stage: 'recovering', + reason: 'native owner outlived the runtime that held its transport' + } + } + // Why: re-adoption is not a new generation, so the fence does not move. + return { disposition: 'readopt' } + } + const evidence = deathEvidenceFor(probe, observedAt) + if (evidence) { + return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), evidence } + } + return { + // Why: an exact recorded identity can still be probed later, so the system keeps + // re-asking; only a record naming nobody (above) needs the user to decide. + disposition: 'recovering', + stage: 'recovering', + reason: + probe.outcome === 'indeterminate' ? probe.reason : 'process identity could not be verified' + } +} + +/** + * A process carrying an Orca spawn token with no matching lease is an orphan: stop it, never + * adopt it. Neither age nor CPU is evidence — only a token match justifies acting on a process. + */ +export function classifyObservedAgentSessionSpawnToken(args: { + spawnToken: string + leases: readonly AgentSessionLease[] +}): 'owned' | 'orphan' { + const owned = args.leases.some( + (lease) => + lease.reservedSpawnToken === args.spawnToken || + lease.ownerProcess?.spawnToken === args.spawnToken + ) + return owned ? 'owned' : 'orphan' +} diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts new file mode 100644 index 00000000000..f488e14e31e --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOperationDecision } from './agent-session-operation-ledger' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from './agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' + +const LEASE = agentSessionLeaseFixture({ + sessionId: 'session-1', + runtimeKind: 'native', + runtimeFence: 4 +}) + +function envelope(overrides: Partial = {}) { + return { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 4, + payloadFingerprint: 'f'.repeat(64), + ...overrides + } +} + +function row(fingerprint: string) { + return { + callerKey: 'caller-1', + operationId: 'op-1', + fingerprint, + operationTimestamp: 1_000, + recordedAt: 1_000, + expiresAt: 100_000, + outcome: { status: 'pending' as const } + } +} + +const ADMIT = (fingerprint: string): AgentSessionOperationDecision => ({ + decision: 'admit', + row: row(fingerprint) +}) + +describe('computeAgentSessionPayloadFingerprint', () => { + it('is stable across key order at every depth', () => { + const a = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', blocks: [{ type: 'text', text: 'hi' }] }, extra: 1 } + }) + const b = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { extra: 1, body: { blocks: [{ text: 'hi', type: 'text' }], kind: 'message' } } + }) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{64}$/) + }) + + it('separates one payload from another and one method from another', () => { + const send = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi there' } + }) + ).not.toBe(send) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + ).not.toBe(send) + }) +}) + +describe('agentSessionFingerprintConflict', () => { + it('refuses a payload that does not match what the client declared', () => { + const conflict = agentSessionFingerprintConflict(envelope(), 'a'.repeat(64)) + expect(conflict?.code).toBe('agent_session_operation_conflict') + }) + + it('passes a matching declaration', () => { + expect(agentSessionFingerprintConflict(envelope(), 'f'.repeat(64))).toBeNull() + }) +}) + +describe('admitAgentSessionMutation', () => { + const base = { envelope: envelope(), hostFingerprint: 'f'.repeat(64), lease: LEASE } + + it('admits a first-time operation under a live lease at the expected fence', () => { + expect(admitAgentSessionMutation({ ...base, ledger: ADMIT('f'.repeat(64)) }).decision).toBe( + 'admit' + ) + }) + + it('replays a recorded operation without re-checking the fence', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 1 }), + ledger: { decision: 'replay', row: row('f'.repeat(64)) } + }) + expect(admission.decision).toBe('replay') + }) + + it('refuses a stale fence and hands back the current one', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 3 }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } + }) + }) + + it('refuses a writer while the lease is unreconciled', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, unreconciled: true }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'execution_owner_reconciling' } + }) + }) + + it('refuses a writer mid-handoff', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, handoffStage: 'new-owner-proving' }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_conflict' } + }) + }) + + it('surfaces a ledger refusal verbatim', () => { + const admission = admitAgentSessionMutation({ + ...base, + ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_operation_expired' } + }) + }) +}) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts new file mode 100644 index 00000000000..aebd2618522 --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.ts @@ -0,0 +1,157 @@ +// Admission for one mutating `agentSession.*` call. +// +// The rules themselves live in the durable ledger and the lease adjudicator; +// this is only the fixed order they are applied in, plus the payload +// fingerprint both peers derive from the same request fields. Nothing here +// re-derives who may write — that answer comes from +// `agentSessionLeaseAdmitsWriter` alone. + +import { createHash } from 'node:crypto' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from './agent-session-operation-ledger' +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' +import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' + +/** + * Stable digest over the fields that define what this call DOES. Keys are + * emitted in sorted order at every depth so two peers serializing the same + * request in different property order agree, and an undefined field is dropped + * rather than hashed as present-but-empty. + */ +export function computeAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const canonical = canonicalize({ + method: input.method, + sessionId: input.sessionId, + fields: input.fields + }) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +/** + * A retry whose payload changed is a different call wearing the same id. + * Checked BEFORE the ledger is consulted, so a refused call never leaves an + * admitted row that a later honest retry would replay as already-done. + */ +export function agentSessionFingerprintConflict( + envelope: AgentSessionMutationEnvelope, + hostFingerprint: string +): AgentSessionWireRefusal | null { + return envelope.payloadFingerprint === hostFingerprint + ? null + : { + code: 'agent_session_operation_conflict', + message: + 'The payload does not match the fingerprint the client declared for this operation.' + } +} + +export type AgentSessionMutationAdmission = + | { decision: 'admit'; row: AgentSessionOperationRow } + /** The recorded outcome answers this call; do not run the effect again. */ + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +/** + * Fixed order: fingerprint agreement, then the ledger (so a retry replays + * before anything else can refuse it), then the lease, then the fence. Putting + * the ledger ahead of the fence is deliberate — a retry that crossed an owner + * change must still return its recorded answer instead of a stale-checkpoint + * refusal the client would then resend as a second effect. + */ +export function admitAgentSessionMutation(input: { + envelope: AgentSessionMutationEnvelope + /** Fingerprint the host computed from the request it actually received. */ + hostFingerprint: string + /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ + ledger: AgentSessionOperationDecision + lease: AgentSessionLease +}): AgentSessionMutationAdmission { + const { envelope, lease, ledger } = input + const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint) + if (mismatch) { + return { decision: 'refused', refusal: mismatch } + } + if (ledger.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: ledger.code, + message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + } + } + } + if (ledger.decision === 'replay') { + return { decision: 'replay', row: ledger.row } + } + const leaseRefusal = refuseUnlessWriterAdmitted(lease) + if (leaseRefusal) { + return { decision: 'refused', refusal: leaseRefusal } + } + if ( + envelope.expectedRuntimeFence === null || + !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) + ) { + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, + currentFence: lease.runtimeFence + } + } + } + return { decision: 'admit', row: ledger.row } +} + +/** Why the single admission oracle said no, mapped to what the client can do + * about it. The predicate itself is never re-implemented here. */ +function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { + if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) { + return null + } + if (lease.unreconciled) { + return { + code: 'execution_owner_reconciling', + message: 'This host has not yet adjudicated the session lease.' + } + } + if (lease.handoffStage !== null) { + return { + code: 'agent_session_conflict', + message: `The session is mid-handoff (${lease.handoffStage}).` + } + } + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { + code: 'agent_session_conflict', + message: 'The agent terminal owns this session.' + } + } + return { + code: 'agent_session_ownership_unknown', + message: 'The session has no live owner to accept writes.' + } +} diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts new file mode 100644 index 00000000000..bf2eb9f50c7 --- /dev/null +++ b/src/shared/agent-session-next-fence.ts @@ -0,0 +1,17 @@ +// The only place a new fence number is chosen. +// +// Normally that is just "one past the current fence". After the record store falls back to its +// backup it is not: the commit that never landed may already have granted a fence the backup cannot +// show, and `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact +// number would hand a second writer a lease the first one still believes it holds. +// +// Recovery records the floor instead of rewriting the current fence, because `live` means a handle +// proven at exactly the current fence — moving it would invalidate the very records recovery exists +// to save. Every mint site routes through here so a new transition cannot quietly reintroduce a +// bare `+ 1`; the floor is pinned by a test that drives each transition. + +import type { AgentSessionLease } from './agent-session-record' + +export function nextAgentSessionFence(lease: AgentSessionLease): number { + return Math.max(lease.runtimeFence + 1, lease.minimumNextFence ?? 0) +} diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts new file mode 100644 index 00000000000..0710eade461 --- /dev/null +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from './agent-session-host-authority' +import { + agentSessionOperationExpiry, + agentSessionOperationKey, + evaluateAgentSessionOperation, + isAgentSessionOperationRow, + pruneAgentSessionOperationRows, + type AgentSessionOperationRow +} from './agent-session-operation-ledger' + +const NOW = 1_800_000_000_000 + +function operationId(timestamp: number, suffix = 'a'.repeat(32)): string { + return `${String(timestamp).padStart(13, '0')}-${suffix}` +} + +function evaluate( + rows: Map, + overrides: Partial<{ + callerKey: string + operationId: string + fingerprint: string + now: number + perClientLimit: number + globalLimit: number + }> = {} +) { + return evaluateAgentSessionOperation({ + rows, + callerKey: 'client-1', + operationId: operationId(NOW), + fingerprint: 'fp-1', + now: NOW, + ...overrides + }) +} + +function admit( + rows: Map, + overrides: Parameters[1] = {} +): AgentSessionOperationRow { + const decision = evaluate(rows, overrides) + if (decision.decision !== 'admit') { + throw new Error(`expected admit, got ${decision.decision}`) + } + rows.set(agentSessionOperationKey(decision.row.callerKey, decision.row.operationId), decision.row) + return decision.row +} + +describe('operation admission', () => { + it('admits a fresh id once and replays the identical retry', () => { + const rows = new Map() + const row = admit(rows) + const replay = evaluate(rows) + expect(replay).toEqual({ decision: 'replay', row }) + }) + + it('refuses the same id carrying different parameters', () => { + const rows = new Map() + admit(rows) + expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict' + }) + }) + + it('scopes ids per caller so two clients cannot collide or replay each other', () => { + const rows = new Map() + admit(rows) + expect(evaluate(rows, { callerKey: 'client-2' }).decision).toBe('admit') + }) + + it('refuses a malformed or future-dated id', () => { + const rows = new Map() + expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_invalid' + }) + // Why: a future-dated id would look new again after its own tombstone is collected. + expect( + evaluate(rows, { + operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1) + }) + ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid' }) + expect( + evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) + .decision + ).toBe('admit') + }) + + it('refuses an id older than the admission window instead of treating it as new', () => { + const rows = new Map() + const stale = operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1) + expect(evaluate(rows, { operationId: stale })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_expired' + }) + expect( + evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) + .decision + ).toBe('admit') + }) + + it('refuses new ids at the per-client and global caps rather than evicting tombstones', () => { + const rows = new Map() + admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) + expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_capacity' + }) + // A different caller is still refused once the global cap is reached. + expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_capacity' + }) + expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') + }) +}) + +describe('retention', () => { + it('keeps a tombstone strictly longer than its id can be admitted as new', () => { + const expiry = agentSessionOperationExpiry(NOW, NOW) + const lastAdmissibleAt = NOW + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + expect(expiry).toBeGreaterThan(lastAdmissibleAt) + // Why: a retry landing in that gap would become a second spawn instead of a replay. + const rows = new Map() + admit(rows) + expect(pruneAgentSessionOperationRows(rows, lastAdmissibleAt).size).toBe(1) + expect(evaluate(pruneAgentSessionOperationRows(rows, lastAdmissibleAt)).decision).toBe('replay') + }) + + it('anchors retention to the later of recording and stamping', () => { + const late = agentSessionOperationExpiry(NOW + 10_000, NOW) + expect(late).toBe(agentSessionOperationExpiry(NOW + 10_000, NOW + 10_000)) + expect(late).toBeGreaterThan(agentSessionOperationExpiry(NOW, NOW)) + }) + + it('drops only rows past their own expiry', () => { + const rows = new Map() + const row = admit(rows) + expect(pruneAgentSessionOperationRows(rows, row.expiresAt).size).toBe(0) + expect(pruneAgentSessionOperationRows(rows, row.expiresAt - 1).size).toBe(1) + }) +}) + +describe('persisted row validation', () => { + it('accepts every recorded outcome shape', () => { + const rows = new Map() + const row = admit(rows) + expect(isAgentSessionOperationRow(row)).toBe(true) + expect( + isAgentSessionOperationRow({ ...row, outcome: { status: 'succeeded', sessionId: 's-1' } }) + ).toBe(true) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'unknown' } })).toBe(true) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'failed', code: 'x' } })).toBe( + true + ) + }) + + it('rejects rows a later build could misread', () => { + const rows = new Map() + const row = admit(rows) + expect(isAgentSessionOperationRow({ ...row, operationId: 'garbage' })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, callerKey: '' })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, expiresAt: 1.5 })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'succeeded' } })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, outcome: null })).toBe(false) + expect(isAgentSessionOperationRow(null)).toBe(false) + }) +}) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts new file mode 100644 index 00000000000..c1f90a9a59c --- /dev/null +++ b/src/shared/agent-session-operation-ledger.ts @@ -0,0 +1,195 @@ +/** + * Durable client-operation ledger. + * + * `terminal.ensureAgentSession` / `terminal.createAgentSession` already enforce timestamped + * operation ids with fingerprint conflict detection, age expiry, capacity limits, and tombstone + * retention — but in memory, so a host restart turns "replay this create" into "spawn another + * agent". These are the same rules over rows that survive a restart; the store writes a row in + * the same atomic transaction as the lease reservation. + */ + +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, + parseAgentSessionOperationTimestamp +} from './agent-session-host-authority' + +export const AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT = 512 +export const AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT = 4_096 + +export type AgentSessionOperationOutcome = + | { status: 'pending' } + | { status: 'succeeded'; sessionId: string } + | { status: 'failed'; code: string; message?: string } + /** The effect may or may not have happened; replay this answer instead of spawning again. */ + | { status: 'unknown' } + +export type AgentSessionOperationRow = { + callerKey: string + operationId: string + fingerprint: string + operationTimestamp: number + recordedAt: number + expiresAt: number + outcome: AgentSessionOperationOutcome +} + +export type AgentSessionOperationRefusalCode = + | 'agent_session_operation_invalid' + | 'agent_session_operation_conflict' + | 'agent_session_operation_expired' + | 'agent_session_operation_capacity' + +export type AgentSessionOperationDecision = + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'admit'; row: AgentSessionOperationRow } + | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + +/** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ +const OPERATION_KEY_SEPARATOR = '\u0000' + +export function agentSessionOperationKey(callerKey: string, operationId: string): string { + return `${callerKey}${OPERATION_KEY_SEPARATOR}${operationId}` +} + +export function settleAgentSessionOperation( + rows: ReadonlyMap, + args: { + /** Restart reconciliation omits this because the lease persists no client identity. */ + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + } +): Map { + const targetKey = args.callerKey + ? agentSessionOperationKey(args.callerKey, args.operationId) + : null + return new Map( + [...rows].map(([key, row]) => [ + key, + (targetKey ? key === targetKey : row.operationId === args.operationId) + ? { ...row, outcome: args.outcome } + : row + ]) + ) +} + +/** + * Retention floor. The tombstone must outlive the window in which its id could still be admitted + * as new, plus the accepted future skew — otherwise a retry arriving in the gap becomes a second + * spawn instead of a replay. + */ +export function agentSessionOperationExpiry( + operationTimestamp: number, + recordedAt: number +): number { + return ( + Math.max(recordedAt, operationTimestamp) + + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) +} + +export function pruneAgentSessionOperationRows( + rows: ReadonlyMap, + now: number +): Map { + const kept = new Map() + for (const [key, row] of rows) { + if (row.expiresAt > now) { + kept.set(key, row) + } + } + return kept +} + +/** + * Decide what a mutating call with this operation id means against the persisted ledger. Callers + * must prune first; a row that is present is a row that is still authoritative. + */ +export function evaluateAgentSessionOperation(args: { + rows: ReadonlyMap + callerKey: string + operationId: string + fingerprint: string + now: number + perClientLimit?: number + globalLimit?: number +}): AgentSessionOperationDecision { + const { rows, callerKey, operationId, fingerprint, now } = args + const operationTimestamp = parseAgentSessionOperationTimestamp(operationId) + if ( + operationTimestamp === null || + operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) { + // Why: a future-dated id could look new again after its tombstone is collected. + return { decision: 'refused', code: 'agent_session_operation_invalid' } + } + const key = agentSessionOperationKey(callerKey, operationId) + const existing = rows.get(key) + if (existing) { + return existing.fingerprint === fingerprint + ? { decision: 'replay', row: existing } + : { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + // Why: once a tombstone could have expired, an unseen replay must never be reinterpreted as + // permission to start another fresh agent. + return { decision: 'refused', code: 'agent_session_operation_expired' } + } + const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT + const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT + let callerCount = 0 + for (const row of rows.values()) { + if (row.callerKey === callerKey) { + callerCount += 1 + } + } + if (callerCount >= perClientLimit || rows.size >= globalLimit) { + // Why: tombstones cannot be evicted early without making an old replay capable of spawning + // again; reject new ids until retained rows age out. + return { decision: 'refused', code: 'agent_session_operation_capacity' } + } + return { + decision: 'admit', + row: { + callerKey, + operationId, + fingerprint, + operationTimestamp, + recordedAt: now, + expiresAt: agentSessionOperationExpiry(operationTimestamp, now), + outcome: { status: 'pending' } + } + } +} + +const OPERATION_ID_MAX_LENGTH = 128 + +export function isAgentSessionOperationRow(value: unknown): value is AgentSessionOperationRow { + if (typeof value !== 'object' || value === null) { + return false + } + const row = value as Partial + const outcome = row.outcome as AgentSessionOperationOutcome | undefined + const outcomeValid = + typeof outcome === 'object' && + outcome !== null && + ((outcome.status === 'pending' && true) || + (outcome.status === 'succeeded' && typeof outcome.sessionId === 'string') || + (outcome.status === 'failed' && typeof outcome.code === 'string') || + outcome.status === 'unknown') + return ( + typeof row.callerKey === 'string' && + row.callerKey.length > 0 && + typeof row.operationId === 'string' && + row.operationId.length <= OPERATION_ID_MAX_LENGTH && + parseAgentSessionOperationTimestamp(row.operationId) !== null && + typeof row.fingerprint === 'string' && + row.fingerprint.length > 0 && + Number.isSafeInteger(row.operationTimestamp) && + Number.isSafeInteger(row.recordedAt) && + Number.isSafeInteger(row.expiresAt) && + outcomeValid + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts new file mode 100644 index 00000000000..538ff638983 --- /dev/null +++ b/src/shared/agent-session-provider-handle.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual, + appendAgentSessionProviderHandleLink, + findAgentSessionProviderHandleLink, + isAgentSessionProviderHandle, + isAgentSessionHandleProvider, + isAgentSessionProviderHandleChain, + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS, + type AgentSessionProviderHandle, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' + +const CLAUDE: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1' +} + +function link(overrides: Partial = {}) { + return { + linkId: 'link-1', + handle: CLAUDE, + origin: 'created', + mintedAtFence: 1, + observedAt: 1_000, + ...overrides + } as AgentSessionProviderHandleLink +} + +describe('handle identity', () => { + it('rejects unknown persisted provider names instead of defaulting to Codex', () => { + expect(isAgentSessionHandleProvider('codex')).toBe(true) + expect(isAgentSessionHandleProvider('claude')).toBe(true) + expect(isAgentSessionHandleProvider('gemini')).toBe(false) + expect(isAgentSessionHandleProvider(undefined)).toBe(false) + }) + + it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => { + // Concurrent resumes branch one transcript silently; the session id alone cannot name a writer. + const branchA = agentSessionProviderHandleKey(CLAUDE) + const branchB = agentSessionProviderHandleKey({ ...CLAUDE, leafUuid: 'leaf-2' }) + expect(branchA).not.toEqual(branchB) + expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual( + agentSessionProviderHandleRoot({ ...CLAUDE, leafUuid: 'leaf-2' }) + ) + }) + + it('keys a Codex handle by thread id alone', () => { + const codex: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-1' } + expect(agentSessionProviderHandleKey(codex)).toBe('codex:"thread-1"') + expect(agentSessionProviderHandleRoot(codex)).toBe('codex:"thread-1"') + expect( + agentSessionProviderHandlesEqual(codex, { provider: 'codex', threadId: 'thread-2' }) + ).toBe(false) + }) + + it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { + expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: null })).toBe(true) + expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ provider: 'claude', sessionId: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, sessionId: ' sess-1 ' })).toBe(false) + }) + + it('uses collision-free keys when Claude ids contain delimiters', () => { + const left: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'a#b', + leafUuid: 'c' + } + const right: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'a', + leafUuid: 'b#c' + } + expect(agentSessionProviderHandleKey(left)).not.toBe(agentSessionProviderHandleKey(right)) + expect(agentSessionProviderHandlesEqual(left, right)).toBe(false) + }) +}) + +describe('chain append', () => { + it('starts only from a created or adopted link', () => { + expect(appendAgentSessionProviderHandleLink([], link())).toEqual([link()]) + expect(appendAgentSessionProviderHandleLink([], link({ origin: 'adopted' }))).toHaveLength(1) + expect(() => appendAgentSessionProviderHandleLink([], link({ origin: 'resumed' }))).toThrow( + 'agent_session_provider_handle_invalid' + ) + }) + + it('refuses to record a fork as a resume', () => { + // --fork-session keeps the original item ids; calling it a resume would claim continuity the + // provider never gave. + const chain = [link()] + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_forked') + }) + + it('records a fork only with a new root and the seed it came from', () => { + const chain = [link()] + const forked = link({ + linkId: 'link-2', + origin: 'forked', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + mintedAtFence: 2, + forkedFromKey: agentSessionProviderHandleKey(CLAUDE) + }) + expect(appendAgentSessionProviderHandleLink(chain, forked)).toHaveLength(2) + expect(() => + appendAgentSessionProviderHandleLink(chain, { ...forked, forkedFromKey: 'claude:other' }) + ).toThrow('agent_session_provider_handle_invalid') + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...forked, + handle: CLAUDE, + forkedFromKey: agentSessionProviderHandleKey(CLAUDE) + }) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('rejects a link minted under an older fence', () => { + const chain = [link({ mintedAtFence: 5 })] + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 4 + }) + ) + ).toThrow('agent_session_provider_handle_stale_fence') + }) + + it('rejects a provider change mid-chain', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [link()], + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'codex', threadId: 'thread-1' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_provider_mismatch') + }) + + it('treats re-proving the same handle at the same fence as a retry, not a new link', () => { + const chain = [link({ mintedAtFence: 3 })] + const retried = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }) + ) + expect(retried).toHaveLength(1) + expect(retried[0]?.linkId).toBe('link-1') + // A later fence on the same handle is a genuine re-acquisition and does append. + expect( + appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 4 }) + ) + ).toHaveLength(2) + }) + + it('rejects reuse of a stable link id for a different proof', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [link()], + link({ + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('refuses to grow past the cap rather than dropping fork provenance', () => { + const chain: AgentSessionProviderHandleLink[] = [link()] + for (let index = 1; index < MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS; index += 1) { + chain.push( + link({ + linkId: `link-${index + 1}`, + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` }, + mintedAtFence: index + 1 + }) + ) + } + expect(chain).toHaveLength(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-overflow', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-overflow' }, + mintedAtFence: 999 + }) + ) + ).toThrow('agent_session_provider_handle_chain_overflow') + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe( + `link-${MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS}` + ) + }) + + it('never mutates the chain it was given', () => { + const chain = [link()] + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + expect(chain).toHaveLength(1) + }) +}) + +describe('chain lookup and validation', () => { + it('finds a link by id and reports the head', () => { + const chain = appendAgentSessionProviderHandleLink( + [link()], + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + expect(findAgentSessionProviderHandleLink(chain, 'link-1')?.origin).toBe('created') + expect(findAgentSessionProviderHandleLink(chain, 'missing')).toBeNull() + expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe('link-2') + expect(agentSessionProviderHandleChainHead([])).toBeNull() + }) + + it('rejects a persisted chain that is over the cap or holds a malformed link', () => { + expect(isAgentSessionProviderHandleChain([{ ...link(), mintedAtFence: -1 }])).toBe(false) + expect(isAgentSessionProviderHandleChain([{ ...link(), linkId: 'not a link id!' }])).toBe(false) + expect(isAgentSessionProviderHandleChain([{ ...link(), forkedFromKey: 'claude:seed' }])).toBe( + false + ) + expect( + isAgentSessionProviderHandleChain( + Array.from({ length: MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + 1 }, (_value, index) => + link({ linkId: `link-${index}` }) + ) + ) + ).toBe(false) + }) + + it('rejects persisted chains that bypass append invariants', () => { + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + linkId: 'link-2', + origin: 'created', + mintedAtFence: 2 + }) + ]) + ).toBe(false) + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ]) + ).toBe(false) + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ]) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts new file mode 100644 index 00000000000..67d6d7f7ce2 --- /dev/null +++ b/src/shared/agent-session-provider-handle.ts @@ -0,0 +1,216 @@ +/** + * Durable provider handle chain for an agent session. + * + * Handles are keyed per provider because the two structured lanes disagree about what + * identifies a conversation. Claude's session id is the identity root and its leaf uuid is a + * branch cursor; Codex's thread id is the whole key. Resumes extend the chain, forks start a new + * identity root, and the chain records which is which so a fork is never presented as a resume. + */ + +export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const + +export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] + +/** Runtime guard for persisted/remote provider metadata. Unknown values must not impersonate Codex. */ +export function isAgentSessionHandleProvider(value: unknown): value is AgentSessionHandleProvider { + return value === 'claude' || value === 'codex' +} + +export type AgentSessionProviderHandle = + | { provider: 'claude'; sessionId: string; leafUuid: string | null } + | { provider: 'codex'; threadId: string } + +export type AgentSessionProviderHandleOrigin = 'created' | 'adopted' | 'resumed' | 'forked' + +export type AgentSessionProviderHandleLink = { + /** Stable id so a lease can name the exact link its owner proved. */ + linkId: string + handle: AgentSessionProviderHandle + origin: AgentSessionProviderHandleOrigin + /** Runtime fence in force when this link was minted; never decreases along the chain. */ + mintedAtFence: number + observedAt: number + /** Key of the link a fork was seeded from. Only set when `origin` is `forked`. */ + forkedFromKey?: string +} + +export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandleLink[] + +/** Bounded so one session cannot grow an unbounded persisted record. */ +export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 + +const MAX_HANDLE_FIELD_LENGTH = 512 +const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +function isHandleField(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_HANDLE_FIELD_LENGTH && + value === value.trim() + ) +} + +export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { + if (typeof value !== 'object' || value === null) { + return false + } + const handle = value as Partial & Record + if (handle.provider === 'claude') { + return ( + isHandleField(handle.sessionId) && + (handle.leafUuid === null || isHandleField(handle.leafUuid)) + ) + } + return handle.provider === 'codex' && isHandleField(handle.threadId) +} + +/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ +export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { + return handle.provider === 'claude' + ? `claude:${JSON.stringify([handle.sessionId, handle.leafUuid])}` + : `codex:${JSON.stringify(handle.threadId)}` +} + +/** + * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, + * whatever the provider called it. + */ +export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { + return handle.provider === 'claude' + ? `claude:${JSON.stringify(handle.sessionId)}` + : `codex:${JSON.stringify(handle.threadId)}` +} + +export function agentSessionProviderHandlesEqual( + left: AgentSessionProviderHandle, + right: AgentSessionProviderHandle +): boolean { + return agentSessionProviderHandleKey(left) === agentSessionProviderHandleKey(right) +} + +export function agentSessionProviderHandleChainHead( + chain: AgentSessionProviderHandleChain +): AgentSessionProviderHandleLink | null { + return chain.at(-1) ?? null +} + +export function findAgentSessionProviderHandleLink( + chain: AgentSessionProviderHandleChain, + linkId: string +): AgentSessionProviderHandleLink | null { + return chain.find((link) => link.linkId === linkId) ?? null +} + +export function isAgentSessionProviderHandleLink( + value: unknown +): value is AgentSessionProviderHandleLink { + if (typeof value !== 'object' || value === null) { + return false + } + const link = value as Partial + const originValid = + link.origin === 'created' || + link.origin === 'adopted' || + link.origin === 'resumed' || + link.origin === 'forked' + return ( + typeof link.linkId === 'string' && + LINK_ID_PATTERN.test(link.linkId) && + isAgentSessionProviderHandle(link.handle) && + originValid && + Number.isSafeInteger(link.mintedAtFence) && + (link.mintedAtFence as number) >= 0 && + Number.isSafeInteger(link.observedAt) && + (link.origin === 'forked' + ? isHandleField(link.forkedFromKey) + : link.forkedFromKey === undefined) + ) +} + +export function isAgentSessionProviderHandleChain( + value: unknown +): value is AgentSessionProviderHandleLink[] { + if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return false + } + let validated: AgentSessionProviderHandleLink[] = [] + try { + for (const link of value) { + if (!isAgentSessionProviderHandleLink(link)) { + return false + } + const next = appendAgentSessionProviderHandleLink(validated, link) + // A persisted chain must name every link exactly once; retry elision belongs at append time. + if (next.length !== validated.length + 1) { + return false + } + validated = next + } + return true + } catch { + return false + } +} + +/** + * Append one link, rejecting anything that would let a fork masquerade as a resume or let a + * late writer rewrite the chain under an older fence. + */ +export function appendAgentSessionProviderHandleLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { + if (!isAgentSessionProviderHandleLink(link)) { + throw new Error('agent_session_provider_handle_invalid') + } + const head = agentSessionProviderHandleChainHead(chain) + if (!head) { + if (link.origin !== 'created' && link.origin !== 'adopted') { + throw new Error('agent_session_provider_handle_invalid') + } + return [link] + } + if (link.handle.provider !== head.handle.provider) { + throw new Error('agent_session_provider_handle_provider_mismatch') + } + if (link.mintedAtFence < head.mintedAtFence) { + throw new Error('agent_session_provider_handle_stale_fence') + } + if (link.origin === 'created' || link.origin === 'adopted') { + throw new Error('agent_session_provider_handle_invalid') + } + const sameRoot = + agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) + if (link.origin === 'resumed' && !sameRoot) { + // Why: a resume that lands on another identity root forked; recording it as a resume would + // make Orca claim continuity the provider never gave. + throw new Error('agent_session_provider_handle_forked') + } + if (link.origin === 'forked') { + if (sameRoot) { + throw new Error('agent_session_provider_handle_invalid') + } + if (link.forkedFromKey !== agentSessionProviderHandleKey(head.handle)) { + throw new Error('agent_session_provider_handle_invalid') + } + } + if ( + link.origin === 'resumed' && + agentSessionProviderHandlesEqual(link.handle, head.handle) && + link.mintedAtFence === head.mintedAtFence + ) { + // Why: re-proving the same handle at the same fence is a retry, not a new identity. + return [...chain] + } + if (findAgentSessionProviderHandleLink(chain, link.linkId)) { + // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. + throw new Error('agent_session_provider_handle_invalid') + } + if (chain.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + // Why: dropping older links would erase fork provenance, so refuse and let the caller roll + // the journal epoch instead of silently losing where this conversation came from. + throw new Error('agent_session_provider_handle_chain_overflow') + } + return [...chain, link] +} diff --git a/src/shared/agent-session-pty-write-admission.test.ts b/src/shared/agent-session-pty-write-admission.test.ts new file mode 100644 index 00000000000..9718fc8d5dc --- /dev/null +++ b/src/shared/agent-session-pty-write-admission.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import { + AgentSessionPtyWriteRefusedError, + describeAgentSessionPtyWriteRefusal, + evaluateAgentSessionPtyWriteAdmission, + isAgentSessionPtyWriteRefusedError, + reevaluateAgentSessionPtyWriteAdmission +} from './agent-session-pty-write-admission' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' +import type { AgentSessionLease } from './agent-session-record' +import { AGENT_SESSION_RPC_ERROR_CODES } from './agent-session-host-authority' + +function bindingFor(lease: AgentSessionLease) { + return { sessionId: lease.sessionId, record: agentSessionRecordFixture(lease) } +} + +describe('exemptions', () => { + it('admits a PTY with no binding, which is every ordinary shell and legacy agent terminal', () => { + const admission = evaluateAgentSessionPtyWriteAdmission(null) + expect(admission).toEqual({ admitted: true, sessionId: null, runtimeFence: null }) + }) + + it('admits a proven-live TUI owner', () => { + const lease = agentSessionLeaseFixture() + expect(evaluateAgentSessionPtyWriteAdmission(bindingFor(lease))).toEqual({ + admitted: true, + sessionId: lease.sessionId, + runtimeFence: lease.runtimeFence + }) + }) +}) + +describe('refusal matrix', () => { + const cases: { name: string; lease: Partial; code: string }[] = [ + { + name: 'native chat owns the session', + lease: { runtimeKind: 'native' }, + code: 'agent_session_conflict' + }, + { + name: 'a handoff is preparing', + lease: { handoffStage: 'preparing' }, + code: 'agent_session_conflict' + }, + { + name: 'the old owner stopped mid-handoff', + lease: { handoffStage: 'old-owner-stopped' }, + code: 'agent_session_conflict' + }, + { + name: 'the new owner has not proved itself', + lease: { handoffStage: 'new-owner-proving' }, + code: 'agent_session_conflict' + }, + { + name: 'two claims collided', + lease: { claimStatus: 'conflicted' }, + code: 'agent_session_conflict' + }, + { + name: 'the lease is unreconciled after a host restart', + lease: { unreconciled: true }, + code: 'execution_owner_reconciling' + }, + { + name: 'recovery is running', + lease: { handoffStage: 'recovering' }, + code: 'execution_owner_reconciling' + }, + { + name: 'a human must finish recovery', + lease: { handoffStage: 'manual-recovery' }, + code: 'execution_owner_reconciling' + }, + { + name: 'the claim is reserved but no process exists yet', + lease: { claimStatus: 'reserved', ownerProcess: null }, + code: 'agent_session_ownership_unknown' + }, + { + name: 'the owner released the session', + lease: { claimStatus: 'released' }, + code: 'agent_session_ownership_unknown' + } + ] + + for (const testCase of cases) { + it(`refuses when ${testCase.name}`, () => { + const lease = agentSessionLeaseFixture(testCase.lease) + const admission = evaluateAgentSessionPtyWriteAdmission(bindingFor(lease)) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe(testCase.code) + expect(admission.refusal.sessionId).toBe(lease.sessionId) + expect(admission.refusal.ownerRuntimeKind).toBe(lease.runtimeKind) + expect(admission.refusal.handoffStage).toBe(lease.handoffStage) + expect(admission.refusal.runtimeFence).toBe(lease.runtimeFence) + }) + } + + it('distinguishes a reconciling window from a session another runtime owns', () => { + // Phase-2 clients render "recovering, retry shortly" only when these two do not collapse. + const reconciling = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ unreconciled: true })) + ) + const owned = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + ) + expect(reconciling.admitted).toBe(false) + expect(owned.admitted).toBe(false) + if (reconciling.admitted || owned.admitted) { + return + } + expect(reconciling.refusal.code).not.toBe(owned.refusal.code) + }) + + it('fails closed when a bound PTY has no readable record', () => { + const admission = evaluateAgentSessionPtyWriteAdmission({ + sessionId: 'session-alpha-1', + record: null + }) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe('execution_owner_reconciling') + expect(admission.refusal.ownerRuntimeKind).toBeNull() + }) + + it('fails closed when the record answers for a different session', () => { + const admission = evaluateAgentSessionPtyWriteAdmission({ + sessionId: 'session-beta-2', + record: agentSessionRecordFixture() + }) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe('agent_session_ownership_unknown') + expect(admission.refusal.sessionId).toBe('session-beta-2') + }) + + it('only emits codes old clients already decode', () => { + const emitted = new Set( + cases.map((testCase) => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture(testCase.lease)) + ) + return admission.admitted ? 'admitted' : admission.refusal.code + }) + ) + for (const code of emitted) { + expect(AGENT_SESSION_RPC_ERROR_CODES).toContain(code) + } + }) +}) + +describe('in-flight fence race', () => { + const admittedLease = agentSessionLeaseFixture() + const admitted = { sessionId: admittedLease.sessionId, runtimeFence: admittedLease.runtimeFence } + + it('lets the rest of a write land while the same fence still holds', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted, + binding: bindingFor(admittedLease) + }) + expect(next.admitted).toBe(true) + }) + + it('refuses the rest of a write once the fence advanced under it', () => { + // A handoff completed between two chunks: the new owner's lease would otherwise admit them. + const moved = agentSessionLeaseFixture({ runtimeFence: admittedLease.runtimeFence + 1 }) + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(moved) }) + expect(next.admitted).toBe(false) + if (next.admitted) { + return + } + expect(next.refusal.code).toBe('agent_session_checkpoint_stale') + expect(next.refusal.runtimeFence).toBe(moved.runtimeFence) + }) + + it('refuses the rest of a write when the PTY was rebound to another session', () => { + const other = agentSessionLeaseFixture({ sessionId: 'session-beta-2' }) + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(other) }) + expect(next.admitted).toBe(false) + }) + + it('refuses the rest of a write when the binding disappeared mid-flight', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: null }) + expect(next.admitted).toBe(false) + }) + + it('refuses the rest of a write when the lease stopped admitting a writer', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted, + binding: bindingFor(agentSessionLeaseFixture({ handoffStage: 'preparing' })) + }) + expect(next.admitted).toBe(false) + if (next.admitted) { + return + } + expect(next.refusal.code).toBe('agent_session_conflict') + }) + + it('judges a write admitted while unbound on whatever binding appeared', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted: { sessionId: null, runtimeFence: null }, + binding: bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + }) + expect(next.admitted).toBe(false) + }) +}) + +describe('typed error', () => { + it('carries the refusal and reports its code as the message', () => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + ) + if (admission.admitted) { + throw new Error('expected a refusal') + } + const error = new AgentSessionPtyWriteRefusedError(admission.refusal) + expect(isAgentSessionPtyWriteRefusedError(error)).toBe(true) + expect(isAgentSessionPtyWriteRefusedError(new Error('agent_session_conflict'))).toBe(false) + expect(error.message).toBe('agent_session_conflict') + expect(error.refusal).toEqual(admission.refusal) + }) + + it('describes who holds the session and what stage it is in', () => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'preparing' })) + ) + if (admission.admitted) { + throw new Error('expected a refusal') + } + const described = describeAgentSessionPtyWriteRefusal(admission.refusal) + expect(described).toContain('session-alpha-1') + expect(described).toContain('native chat') + expect(described).toContain('pid 4242') + expect(described).toContain('preparing') + }) +}) diff --git a/src/shared/agent-session-pty-write-admission.ts b/src/shared/agent-session-pty-write-admission.ts new file mode 100644 index 00000000000..78f9b915f69 --- /dev/null +++ b/src/shared/agent-session-pty-write-admission.ts @@ -0,0 +1,177 @@ +/** + * PTY-write admission for agent sessions that have a durable record. + * + * A PTY is the TUI runtime's write surface, so bytes may enter it only while the session's lease + * admits a writer and that writer is the TUI. The admit decision is `agentSessionLeaseAdmitsWriter` + * verbatim; everything here only decides whether the lease is even the TUI's to hold, and — once + * that helper has already refused — which refusal a client should be shown. + * + * A PTY with no binding is a session this host knows nothing about: every ordinary shell and every + * legacy agent terminal. Those are never consulted and never refused. A binding whose record is + * missing is the opposite case — the record was lost, not absent — and fails closed. + */ + +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { + AgentSessionHandoffStage, + AgentSessionLease, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from './agent-session-record' + +/** + * Every code is already in `AGENT_SESSION_RPC_ERROR_CODES`, so a refusal reaching an old client + * carries a code it has seen since the host-authority release rather than a new one. + */ +export type AgentSessionPtyWriteRefusalCode = + | 'agent_session_conflict' + | 'agent_session_ownership_unknown' + | 'agent_session_checkpoint_stale' + | 'execution_owner_reconciling' + +export type AgentSessionPtyWriteRefusal = { + code: AgentSessionPtyWriteRefusalCode + sessionId: string + /** Runtime the lease names as owner; null once the record is gone. */ + ownerRuntimeKind: AgentSessionOwnerRuntimeKind | null + handoffStage: AgentSessionHandoffStage | null + /** Pid the lease names, so the refusal can say who holds the session. */ + ownerPid: number | null + runtimeFence: number | null +} + +export type AgentSessionPtyWriteAdmission = + | { admitted: true; sessionId: string | null; runtimeFence: number | null } + | { admitted: false; refusal: AgentSessionPtyWriteRefusal } + +/** One PTY's durable binding: the session it belongs to and that session's record, if readable. */ +export type AgentSessionPtyBinding = { + sessionId: string + record: AgentSessionRecord | null +} + +const UNBOUND_ADMISSION: AgentSessionPtyWriteAdmission = { + admitted: true, + sessionId: null, + runtimeFence: null +} + +/** Runs only after `agentSessionLeaseAdmitsWriter` (or the runtime-kind test) already refused. */ +function classifyRefusal(lease: AgentSessionLease): AgentSessionPtyWriteRefusalCode { + if (lease.unreconciled) { + return 'execution_owner_reconciling' + } + if (lease.claimStatus === 'conflicted') { + return 'agent_session_conflict' + } + if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { + return 'execution_owner_reconciling' + } + if (lease.handoffStage !== null || lease.runtimeKind !== 'tui') { + // Why: a live native owner and a mid-flight handoff are both "someone else holds it", which is + // actionable in a way "we cannot tell" is not. + return 'agent_session_conflict' + } + return 'agent_session_ownership_unknown' +} + +function refuse( + code: AgentSessionPtyWriteRefusalCode, + sessionId: string, + lease: AgentSessionLease | null +): AgentSessionPtyWriteAdmission { + return { + admitted: false, + refusal: { + code, + sessionId, + ownerRuntimeKind: lease?.runtimeKind ?? null, + handoffStage: lease?.handoffStage ?? null, + ownerPid: lease?.ownerProcess?.pid ?? null, + runtimeFence: lease?.runtimeFence ?? null + } + } +} + +export function evaluateAgentSessionPtyWriteAdmission( + binding: AgentSessionPtyBinding | null +): AgentSessionPtyWriteAdmission { + if (!binding) { + return UNBOUND_ADMISSION + } + const record = binding.record + if (!record) { + // Why: a bound PTY whose record cannot be read is a lost lease, not an unmanaged shell. + return refuse('execution_owner_reconciling', binding.sessionId, null) + } + if (record.sessionId !== binding.sessionId) { + return refuse('agent_session_ownership_unknown', binding.sessionId, record.lease) + } + const lease = record.lease + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { admitted: true, sessionId: record.sessionId, runtimeFence: lease.runtimeFence } + } + return refuse(classifyRefusal(lease), binding.sessionId, lease) +} + +/** + * Re-admit a write that already began. Chunked input and the text/suffix pause both yield, so a + * lease transition can land between two writes of one logical send; the fence observed at + * admission is what the remaining bytes are checked against. + */ +export function reevaluateAgentSessionPtyWriteAdmission(args: { + admitted: { sessionId: string | null; runtimeFence: number | null } + binding: AgentSessionPtyBinding | null +}): AgentSessionPtyWriteAdmission { + const { admitted, binding } = args + const next = evaluateAgentSessionPtyWriteAdmission(binding) + if (admitted.sessionId === null || admitted.runtimeFence === null) { + // Why: an unbound write that acquires a binding mid-flight is judged on the new binding alone. + return next + } + if (!next.admitted) { + return next + } + const lease = binding?.record?.lease ?? null + if ( + next.sessionId !== admitted.sessionId || + !lease || + !isAgentSessionFenceCurrent(lease, admitted.runtimeFence) + ) { + return refuse('agent_session_checkpoint_stale', admitted.sessionId, lease) + } + return next +} + +export class AgentSessionPtyWriteRefusedError extends Error { + readonly refusal: AgentSessionPtyWriteRefusal + + constructor(refusal: AgentSessionPtyWriteRefusal) { + // Why: callers that already switch on `error.message` as an RPC code keep working unchanged. + super(refusal.code) + this.name = 'AgentSessionPtyWriteRefusedError' + this.refusal = refusal + } +} + +export function isAgentSessionPtyWriteRefusedError( + error: unknown +): error is AgentSessionPtyWriteRefusedError { + return error instanceof AgentSessionPtyWriteRefusedError +} + +/** Human-readable refusal, so a client that only surfaces a message still names the owner. */ +export function describeAgentSessionPtyWriteRefusal(refusal: AgentSessionPtyWriteRefusal): string { + const owner = + refusal.ownerRuntimeKind === null + ? 'no recorded owner' + : `${refusal.ownerRuntimeKind === 'native' ? 'native chat' : 'the agent TUI'}${ + refusal.ownerPid === null ? '' : ` (pid ${refusal.ownerPid})` + }` + const stage = + refusal.handoffStage === null ? 'no handoff in progress' : `handoff ${refusal.handoffStage}` + return `Agent session ${refusal.sessionId} is held by ${owner}; ${stage} (${refusal.code}).` +} diff --git a/src/shared/agent-session-pty-write-refusal-copy.ts b/src/shared/agent-session-pty-write-refusal-copy.ts new file mode 100644 index 00000000000..6820b6ce44b --- /dev/null +++ b/src/shared/agent-session-pty-write-refusal-copy.ts @@ -0,0 +1,13 @@ +import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' + +export function structuredChatPtyWriteRefusalCopy( + refusal: AgentSessionPtyWriteRefusal, + action: 'terminal-send' | 'worker-start' +): string | null { + if (refusal.ownerRuntimeKind !== 'native') { + return null + } + return action === 'worker-start' + ? 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + : 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca terminal send`.' +} diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts new file mode 100644 index 00000000000..65da67c571e --- /dev/null +++ b/src/shared/agent-session-record.test-fixture.ts @@ -0,0 +1,67 @@ +/** Durable-record fixtures shared by the write-admission tests across shared, runtime, and IPC. */ + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionLease, + type AgentSessionRecord +} from './agent-session-record' + +const OWNER_PROCESS = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-tui' +} + +/** A proven-live TUI owner: the only lease state that admits a PTY write. */ +export function agentSessionLeaseFixture( + overrides: Partial = {} +): AgentSessionLease { + return { + sessionId: 'session-alpha-1', + runtimeKind: 'tui', + runtimeFence: 7, + handoffStage: null, + provenHandleLinkId: 'link-1', + ownerProcess: OWNER_PROCESS, + reservedSpawnToken: 'spawn-tui', + leaseDeadlineAt: 60_000, + lastRenewedAt: 30_000, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null, + ...overrides + } +} + +export function agentSessionRecordFixture( + lease: AgentSessionLease = agentSessionLeaseFixture() +): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: lease.sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + providerHandleChain: [ + { + linkId: 'link-1', + origin: 'created', + mintedAtFence: lease.runtimeFence, + observedAt: 1_000, + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, + lease, + createdAt: 1_000, + updatedAt: 2_000 + } +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts new file mode 100644 index 00000000000..f81e1461218 --- /dev/null +++ b/src/shared/agent-session-record.ts @@ -0,0 +1,357 @@ +/** + * Durable agent-session record and its single-writer lease. + * + * The record is the session's identity — where it runs, which provider it talks to, which account + * home is pinned to it — and is independent of any terminal tab. The lease is the separate + * question of which process is currently allowed to write to it. + */ + +import type { ExecutionHostId } from './execution-host' +import { + isAgentSessionProviderHandleChain, + type AgentSessionHandleProvider, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' + +export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const + +export type AgentSessionWorkspaceKind = 'git-worktree' | 'folder' + +/** + * Where the provider process actually runs. WSL is called out separately from the execution host + * id because a WSL workspace is served by the local host but is a distinct filesystem, account + * root, and process namespace — two sessions there must never collide with their native twins. + */ +export type AgentSessionExecutionLocation = { + executionHostId: ExecutionHostId + /** Distro name when the provider runs inside WSL; null for native and remote hosts. */ + wslDistro: string | null + workspaceId: string + workspaceKind: AgentSessionWorkspaceKind +} + +/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ +export type AgentSessionAccountHome = { + variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME' + /** Host-resolved absolute path in the execution host's own path syntax. */ + path: string +} + +/** Provider launch environment captured by the host when the session is created. */ +export type AgentSessionLaunchEnv = Record + +/** Provider CLI arguments captured by the host when the session is created. */ +export type AgentSessionLaunchArgs = string[] + +export type AgentSessionOwnerRuntimeKind = 'native' | 'tui' + +export type AgentSessionHandoffStage = + | 'preparing' + | 'old-owner-stopped' + | 'new-owner-proving' + | 'recovering' + | 'manual-recovery' + +/** + * PID-reuse-safe process identity. `spawnToken` is the only element available on every platform: + * process start time costs a CIM query on Windows and is absent in some containers. An exact + * identity stays in `recovering`; an ownerless, unattributable reservation uses `manual-recovery`. + */ +export type AgentSessionProcessIdentity = { + hostId: string + pid: number + processStartTimeMs: number | null + spawnToken: string +} + +export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number } + +/** + * Mirrors the in-memory claim registry's reserved / live / conflicted states so a conflict + * survives a restart. `released` has no registry equivalent: the registry expresses "no owner" by + * deleting the entry, and a durable record that outlives its owner needs a name for that. + */ +export type AgentSessionClaimStatus = 'reserved' | 'live' | 'conflicted' | 'released' + +export type AgentSessionDeathEvidence = { + kind: 'exit-observed' | 'pid-absent' | 'identity-mismatch' + detail: string + observedAt: number +} + +export type AgentSessionLease = { + sessionId: string + runtimeKind: AgentSessionOwnerRuntimeKind + /** Durable monotonic integer; only acquisition CAS and proven eviction move it. */ + runtimeFence: number + handoffStage: AgentSessionHandoffStage | null + /** Link id of the provider handle this owner proved; the full chain lives on the record. */ + provenHandleLinkId: string | null + /** Null between the durable reservation and the observed spawn. */ + ownerProcess: AgentSessionProcessIdentity | null + /** Reserved before any process exists, then matched against the child's environment. */ + reservedSpawnToken: string | null + /** Set only when acquisition failed before any spawn attempt. */ + processlessAt?: number | null + leaseDeadlineAt: number + lastRenewedAt: number + handoffOperationId: string | null + journalCheckpoint: AgentSessionJournalCheckpoint | null + /** Key id that minted the HMAC claim this lease was granted under. */ + claimKeyId: string + claimStatus: AgentSessionClaimStatus + /** True from load until the host adjudicates it; no writer is granted while set. */ + unreconciled: boolean + /** + * Lowest fence a future grant may use. Set only after the store recovers from its backup, where + * the commit that never landed may already have granted a fence the backup cannot show. The + * CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number, + * so rewriting it would invalidate the record it is trying to save. + */ + minimumNextFence?: number + deathEvidence: AgentSessionDeathEvidence | null +} + +export type AgentSessionRecord = { + schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION + sessionId: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + providerHandleChain: AgentSessionProviderHandleLink[] + accountHome: AgentSessionAccountHome + /** Provider options acknowledged for the next turn, restored across owner replacement. */ + options?: Record + launchArgs?: AgentSessionLaunchArgs + lease: AgentSessionLease + createdAt: number + updatedAt: number +} + +export type AgentSessionOptionsReplacement = { + sessionId: string + fence: number + options: Readonly> + now: number +} + +const MAX_ID_LENGTH = 512 +const MAX_PATH_LENGTH = 4096 +const MAX_LAUNCH_ENV_ENTRIES = 256 +const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536 +const MAX_LAUNCH_ARGS = 256 +const MAX_LAUNCH_ARGS_BYTES = 16 * 1024 +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/ + +function isBoundedString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max +} + +export function isAgentSessionId(value: unknown): value is string { + return typeof value === 'string' && SESSION_ID_PATTERN.test(value) +} + +/** NUL cannot occur in a host id, distro name, or workspace id, so no component can forge a join. */ +const SCOPE_KEY_SEPARATOR = '\u0000' + +/** + * Scope key for host-and-workspace isolation. Native, WSL, and SSH copies of one workspace id are + * different sessions; collapsing them would let one host adjudicate another host's lease. + */ +export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string { + return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join( + SCOPE_KEY_SEPARATOR + ) +} + +export function agentSessionExecutionLocationsEqual( + left: AgentSessionExecutionLocation, + right: AgentSessionExecutionLocation +): boolean { + return ( + agentSessionScopeKey(left) === agentSessionScopeKey(right) && + left.workspaceKind === right.workspaceKind + ) +} + +export function isAgentSessionExecutionLocation( + value: unknown +): value is AgentSessionExecutionLocation { + if (typeof value !== 'object' || value === null) { + return false + } + const location = value as Partial + return ( + isBoundedString(location.executionHostId, MAX_ID_LENGTH) && + (location.wslDistro === null || isBoundedString(location.wslDistro, MAX_ID_LENGTH)) && + isBoundedString(location.workspaceId, MAX_ID_LENGTH) && + (location.workspaceKind === 'git-worktree' || location.workspaceKind === 'folder') + ) +} + +export function isAgentSessionProcessIdentity( + value: unknown +): value is AgentSessionProcessIdentity { + if (typeof value !== 'object' || value === null) { + return false + } + const identity = value as Partial + return ( + isBoundedString(identity.hostId, MAX_ID_LENGTH) && + Number.isSafeInteger(identity.pid) && + (identity.pid as number) > 0 && + (identity.processStartTimeMs === null || + (Number.isSafeInteger(identity.processStartTimeMs) && + (identity.processStartTimeMs as number) >= 0)) && + isBoundedString(identity.spawnToken, MAX_ID_LENGTH) + ) +} + +function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { + if (typeof value !== 'object' || value === null) { + return false + } + const home = value as Partial + return ( + (home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') && + isBoundedString(home.path, MAX_PATH_LENGTH) + ) +} + +function isAgentSessionOptions(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const entries = Object.entries(value) + return ( + entries.length <= 32 && + entries.every( + ([key, option]) => + isBoundedString(key, MAX_ID_LENGTH) && isBoundedString(option, MAX_ID_LENGTH) + ) + ) +} + +export function isAgentSessionLaunchEnv(value: unknown): value is AgentSessionLaunchEnv { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const entries = Object.entries(value) + return ( + entries.length <= MAX_LAUNCH_ENV_ENTRIES && + entries.every( + ([key, entry]) => + isBoundedString(key, MAX_ID_LENGTH) && + typeof entry === 'string' && + entry.length <= MAX_LAUNCH_ENV_VALUE_LENGTH + ) + ) +} + +function isAgentSessionJournalCheckpoint(value: unknown): value is AgentSessionJournalCheckpoint { + if (typeof value !== 'object' || value === null) { + return false + } + const checkpoint = value as Partial + return ( + Number.isSafeInteger(checkpoint.epoch) && + (checkpoint.epoch as number) >= 0 && + Number.isSafeInteger(checkpoint.sequence) && + (checkpoint.sequence as number) >= 0 + ) +} + +function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeathEvidence { + if (typeof value !== 'object' || value === null) { + return false + } + const evidence = value as Partial + return ( + (evidence.kind === 'exit-observed' || + evidence.kind === 'pid-absent' || + evidence.kind === 'identity-mismatch') && + isBoundedString(evidence.detail, MAX_ID_LENGTH) && + Number.isSafeInteger(evidence.observedAt) && + (evidence.observedAt as number) >= 0 + ) +} + +function isAgentSessionLease(value: unknown): value is AgentSessionLease { + if (typeof value !== 'object' || value === null) { + return false + } + const lease = value as Partial + return ( + isAgentSessionId(lease.sessionId) && + (lease.runtimeKind === 'native' || lease.runtimeKind === 'tui') && + Number.isSafeInteger(lease.runtimeFence) && + (lease.runtimeFence as number) >= 0 && + (lease.handoffStage === null || + lease.handoffStage === 'preparing' || + lease.handoffStage === 'old-owner-stopped' || + lease.handoffStage === 'new-owner-proving' || + lease.handoffStage === 'recovering' || + lease.handoffStage === 'manual-recovery') && + (lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) && + (lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) && + (lease.reservedSpawnToken === null || + isBoundedString(lease.reservedSpawnToken, MAX_ID_LENGTH)) && + (lease.processlessAt === undefined || + lease.processlessAt === null || + (Number.isSafeInteger(lease.processlessAt) && (lease.processlessAt as number) >= 0)) && + Number.isSafeInteger(lease.leaseDeadlineAt) && + Number.isSafeInteger(lease.lastRenewedAt) && + (lease.handoffOperationId === null || + isBoundedString(lease.handoffOperationId, MAX_ID_LENGTH)) && + (lease.journalCheckpoint === null || + isAgentSessionJournalCheckpoint(lease.journalCheckpoint)) && + isBoundedString(lease.claimKeyId, MAX_ID_LENGTH) && + (lease.claimStatus === 'reserved' || + lease.claimStatus === 'live' || + lease.claimStatus === 'conflicted' || + lease.claimStatus === 'released') && + typeof lease.unreconciled === 'boolean' && + (lease.deathEvidence === null || isAgentSessionDeathEvidence(lease.deathEvidence)) + ) +} + +export function isAgentSessionRecord(value: unknown): value is AgentSessionRecord { + if (typeof value !== 'object' || value === null) { + return false + } + const record = value as Partial + const shapeValid = + record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && + isAgentSessionId(record.sessionId) && + isAgentSessionExecutionLocation(record.location) && + (record.provider === 'claude' || record.provider === 'codex') && + isAgentSessionProviderHandleChain(record.providerHandleChain) && + isAgentSessionAccountHome(record.accountHome) && + (record.options === undefined || isAgentSessionOptions(record.options)) && + (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && + !Object.hasOwn(record, 'launchEnv') && + isAgentSessionLease(record.lease) && + record.lease.sessionId === record.sessionId && + Number.isSafeInteger(record.createdAt) && + Number.isSafeInteger(record.updatedAt) + if (!shapeValid) { + return false + } + const validated = record as AgentSessionRecord + const head = validated.providerHandleChain.at(-1) + return ( + validated.providerHandleChain.every((link) => link.handle.provider === validated.provider) && + (validated.lease.claimStatus !== 'live' || + (validated.lease.ownerProcess !== null && + head?.linkId === validated.lease.provenHandleLinkId && + head.mintedAtFence === validated.lease.runtimeFence)) + ) +} + +export function isAgentSessionLaunchArgs(value: unknown): value is AgentSessionLaunchArgs { + return ( + Array.isArray(value) && + value.length <= MAX_LAUNCH_ARGS && + value.every((arg) => typeof arg === 'string' && !arg.includes('\0')) && + Buffer.byteLength(JSON.stringify(value), 'utf8') <= MAX_LAUNCH_ARGS_BYTES + ) +} diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts new file mode 100644 index 00000000000..94c8f65c413 --- /dev/null +++ b/src/shared/agent-session-refusal-retry.ts @@ -0,0 +1,39 @@ +import type { AgentSessionWireRefusalCode } from './agent-session-wire' + +export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' + +const HANDOFF_SETTLED_REFUSALS = new Set([ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_operation_conflict' +]) + +export function agentSessionRefusalOperationState( + method: string, + code: AgentSessionWireRefusalCode +): AgentSessionRefusalOperationState { + if (method === 'agentSession.requestHandoff' && HANDOFF_SETTLED_REFUSALS.has(code)) { + return 'settled-rejected' + } + switch (code) { + case 'agent_session_operation_conflict': + case 'agent_session_operation_expired': + case 'agent_session_operation_invalid': + case 'agent_session_item_revision_stale': + case 'agent_session_already_resolved': + return 'settled-rejected' + case 'agent_session_operation_unknown': + return 'unknown' + case 'structured_agent_session_unsupported': + case 'agent_session_checkpoint_stale': + case 'agent_session_conflict': + case 'agent_session_ownership_unknown': + case 'agent_session_operation_capacity': + case 'agent_session_identity_required': + case 'agent_session_journal_unreadable': + case 'execution_owner_reconciling': + // These refusals do not prove the operation reached durable settlement. + return 'pending-admission' + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts new file mode 100644 index 00000000000..893534f7f10 --- /dev/null +++ b/src/shared/agent-session-wire.ts @@ -0,0 +1,258 @@ +// ─── Structured agent-session wire contract ───────────────────────────────── +// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider +// adapters and clients against exactly these types, so everything here must be +// plain JSON. The whole surface is gated by agent-session.structured.v1, which +// no released baseline advertises; after that capability ships, every new field +// must remain optional to old readers (docs/reference/remote-wire-compatibility.md). + +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalResetReason, + AgentJournalResolution, + AgentJournalSubmission +} from './agent-session-journal-types' +import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' +import type { AgentProviderSessionMetadata } from './agent-session-resume' + +export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' +export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' +export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover' + +export type AgentSessionHandoffStatus = { + owner: AgentSessionOwnerRuntimeKind | 'none' + direction: AgentSessionHandoffDirection | null + phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + stage: AgentSessionHandoffStage | null + operationId: string | null + hostLabel?: string + terminal?: { + handle: string + tabId: string + paneKey: string + ptyId?: string + } + error?: { + message: string + details?: string + recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' + canRetryProof?: boolean + } +} + +export type AgentSessionHandoffRequest = { + envelope: AgentSessionMutationEnvelope + direction: AgentSessionHandoffDirection + mode: AgentSessionHandoffMode + action?: AgentSessionHandoffAction +} + +export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } + +/** Backward paging is the client's normal read; 40 matches the page size the + * mobile list renders without a visible fill-in. */ +export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 +export const AGENT_SESSION_HISTORY_MAX_LIMIT = 200 + +export const AGENT_SESSION_HISTORY_DIRECTIONS = ['tail', 'before', 'after'] as const +/** `tail` is the newest page, `before` pages backward, `after` catches a live + * reader up. Only `after` needs replayable rows; the other two read the + * reduced timeline and so survive compaction. */ +export type AgentSessionHistoryDirection = (typeof AGENT_SESSION_HISTORY_DIRECTIONS)[number] + +export type AgentSessionHistoryRequest = { + sessionId: string + direction: AgentSessionHistoryDirection + /** Required for `before` and `after`; ignored for `tail`. */ + cursor?: AgentJournalCursor + limit?: number +} + +export type AgentSessionHistoryPage = { + sessionId: string + epoch: string + /** Optional for mixed-version readers; write-capable clients use the + * checkpoint without forcing a second attach or a redundant snapshot. */ + fence?: number + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + /** Populated by `after` reads so a disconnected client can apply tombstones. */ + removedItemIds: string[] + /** Submissions overlapping this page, so an unconfirmed bubble renders with + * its dispatch state instead of as a plain message. */ + submissions: AgentJournalSubmission[] + /** Page edges. `nextCursor` is what the client sends back for the same + * direction; it equals the request cursor when the page is empty. */ + window: { + oldest: AgentJournalCursor | null + newest: AgentJournalCursor | null + nextCursor: AgentJournalCursor + } + /** Current journal head for switching from a bounded page to live subscribe. */ + liveCursor?: AgentJournalCursor + hasOlder: boolean + hasNewer: boolean +} + +export type AgentSessionHistoryResult = + | { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata } + /** Every reset carries a byte-bounded tail page so recovery cannot exceed + * remote outbound admission or require another call before resubscribing. */ + | { + ok: false + reset: AgentJournalResetReason + page: AgentSessionHistoryPage + fence?: number + providerSession?: AgentProviderSessionMetadata + } + +/** Cursor-qualified incremental publication. Items and submissions carry their + * CURRENT reduced state rather than a delta, so applying a batch twice + * converges instead of double-appending. */ +export type AgentSessionJournalBatch = { + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + removedItemIds: string[] + submissions: AgentJournalSubmission[] +} + +export type AgentSessionSubscribeEvent = + | { + type: 'snapshot' + sessionId: string + page: AgentSessionHistoryPage + fence: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'batch' + sessionId: string + batch: AgentSessionJournalBatch + /** Added with handoff state so mixed-version cursors retain the ownership fence. */ + fence?: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'reset' + sessionId: string + reset: AgentJournalResetReason + page: AgentSessionHistoryPage + fence: number + handoff?: AgentSessionHandoffStatus + } + | { type: 'end' } + +// ─── Mutation envelope ────────────────────────────────────────────────────── + +/** + * The four fields every mutating call carries. Same operation id and same + * fingerprint replays the recorded outcome; a different fingerprint under one + * operation id is a conflict, never a second effect. + */ +export type AgentSessionMutationEnvelope = { + sessionId: string + clientOperationId: string + /** Null only on a create for a session that does not exist yet. */ + expectedRuntimeFence: number | null + /** Client-declared; the host recomputes it and compares. */ + payloadFingerprint: string +} + +export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_operation_invalid', + 'agent_session_operation_unknown', + 'agent_session_item_revision_stale', + 'agent_session_already_resolved', + 'agent_session_identity_required', + 'agent_session_journal_unreadable', + 'execution_owner_reconciling' +] as const +export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + +export type AgentSessionWireRefusal = { + code: AgentSessionWireRefusalCode + message: string + /** On a stale fence, so the client can retry without another round trip. */ + currentFence?: number + /** On a lost compare-and-set: the winning answer and who gave it. */ + resolution?: AgentJournalResolution + /** On a lost compare-and-set: the revision the host actually holds. */ + currentRevision?: number +} + +export type AgentSessionMutationResult = + | { + ok: true + /** True when the recorded outcome was returned instead of a new effect. */ + replayed: boolean + fence: number + cursor: AgentJournalCursor + value: TValue + } + | { ok: false; refusal: AgentSessionWireRefusal } + +// ─── Per-method payloads ──────────────────────────────────────────────────── + +export type AgentSessionAttachResult = { + sessionId: string + fence: number + page: AgentSessionHistoryPage + /** Submissions the crash boundary settled as `unknown` while attaching. */ + unconfirmedClientMessageIds: string[] +} + +export type AgentSessionSendResult = { + clientMessageId: string + submission: AgentJournalSubmission +} + +export type AgentSessionCancelResult = { + /** The turn the client named, echoed so a late reply can be matched. */ + turnId: string + cancelled: boolean +} + +export type AgentSessionPromptResult = { + itemId: string + revision: number + resolution: AgentJournalResolution +} + +export type AgentSessionOptionResult = { + key: string + value: string + /** Full effective next-turn values when the provider reconciled related options. */ + options?: Record +} + +export type AgentSessionOptionChoice = { + value: string + label: string + description?: string +} + +export type AgentSessionModelOption = { + id: string + label: string + description?: string + isDefault: boolean + defaultEffort?: string + efforts: AgentSessionOptionChoice[] +} + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ +export type AgentSessionOptionsResult = { + models: AgentSessionModelOption[] + current: { + model: string + effort?: string + } +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index de177dcd4b8..70509ea495a 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -154,6 +154,8 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata + /** False when the status belongs to a non-terminal owner that restores itself. */ + terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** True for a nonterminal state hydrated from last-status.json with no live hook since: diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 39edd01038a..64797710537 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -3,7 +3,8 @@ import type { AiVaultSession } from './ai-vault-types' export type AiVaultPrepareSessionResumeArgs = Pick< AiVaultSession, 'agent' | 'filePath' | 'codexHome' | 'executionHostId' -> +> & + Partial> export type AiVaultPrepareSessionResumeResult = { useRealCodexHome: boolean diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 00f25510922..6adc59c0396 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -120,6 +120,11 @@ export type AiVaultSession = { subagentTranscriptCount: number resumeCommand: string subagent: AiVaultSessionSubagentInfo | null + /** Present only when the negotiated client can open the native structured owner. */ + structuredSession?: { + sessionId: string + workspaceId: string + } } export type AiVaultSubagentListArgs = { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f..874ab972787 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -14,6 +14,8 @@ # `detached: true` for a process-group kill, a synchronous taskkill, and an stdio shape other # than all-pipes (a long-lived `ssh -N` must not be handed a stdin pipe nobody closes). # Migrating them means teaching run-process.ts those options first. +# codex-app-server-posix-supervisor.ts requires child_process only inside its +# dependency-free POSIX supervisor script, never in Orca's process. # # The src/main/git/command-runner/* entries are the seven spawn sites that used # to live in the single line src/main/git/runner.ts; splitting that file moved @@ -44,6 +46,7 @@ src/main/claude-accounts/keychain.ts src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts +src/main/codex/codex-app-server-posix-supervisor.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts src/main/codex/codex-wsl-hook-install-plan.ts diff --git a/src/shared/child-process/cancel-process-acquisition.ts b/src/shared/child-process/cancel-process-acquisition.ts new file mode 100644 index 00000000000..073e6998c45 --- /dev/null +++ b/src/shared/child-process/cancel-process-acquisition.ts @@ -0,0 +1,27 @@ +type ExitProvenConnection = { + close: () => Promise +} + +export async function cancelProcessAcquisition(input: { + cancel: () => void + connection: () => ExitProvenConnection | null + exitProven: () => boolean + finished: Promise +}): Promise { + input.cancel() + const connectionBeforeFinish = input.connection() + if (connectionBeforeFinish) { + if ((await connectionBeforeFinish.close()) !== true) { + return false + } + } + await input.finished + if (input.exitProven()) { + return true + } + const connectionAfterFinish = input.connection() + if (!connectionAfterFinish || connectionAfterFinish === connectionBeforeFinish) { + return true + } + return (await connectionAfterFinish.close()) === true +} diff --git a/src/shared/child-process/close-process-registry.test.ts b/src/shared/child-process/close-process-registry.test.ts new file mode 100644 index 00000000000..6a3831a1e56 --- /dev/null +++ b/src/shared/child-process/close-process-registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { closeProcessRegistry } from './close-process-registry' + +describe('closeProcessRegistry', () => { + it('continues closing sibling processes when one close rejects', async () => { + const entries = new Set(['first', 'second']) + const closeEntry = vi.fn(async (id: string) => { + if (id === 'first' && closeEntry.mock.calls.filter(([entry]) => entry === id).length === 1) { + throw new Error('transient close failure') + } + entries.delete(id) + return true + }) + + await expect( + closeProcessRegistry({ + attempts: 3, + hasEntries: () => entries.size > 0, + entryIds: () => entries, + closeEntry, + failureMessage: 'processes remain live' + }) + ).resolves.toBeUndefined() + + expect(closeEntry.mock.calls.map(([id]) => id)).toEqual(['first', 'second', 'first']) + }) + + it('reports every rejected proof after the bounded retries', async () => { + const failure = new Error('close failed') + + await expect( + closeProcessRegistry({ + attempts: 3, + hasEntries: () => true, + entryIds: () => ['session-1'], + closeEntry: async () => { + throw failure + }, + failureMessage: 'processes remain live' + }) + ).rejects.toMatchObject({ + message: 'processes remain live', + errors: [failure, failure, failure] + }) + }) +}) diff --git a/src/shared/child-process/close-process-registry.ts b/src/shared/child-process/close-process-registry.ts new file mode 100644 index 00000000000..04b5282aff5 --- /dev/null +++ b/src/shared/child-process/close-process-registry.ts @@ -0,0 +1,26 @@ +export async function closeProcessRegistry(input: { + attempts: number + hasEntries: () => boolean + entryIds: () => Iterable + closeEntry: (id: string) => Promise + failureMessage: string +}): Promise { + const errors: unknown[] = [] + for (let attempt = 0; attempt < input.attempts && input.hasEntries(); attempt += 1) { + await Promise.all( + [...input.entryIds()].map(async (id) => { + try { + await input.closeEntry(id) + } catch (error) { + errors.push(error) + } + }) + ) + } + if (!input.hasEntries()) { + return + } + throw errors.length > 0 + ? new AggregateError(errors, input.failureMessage) + : new Error(input.failureMessage) +} diff --git a/src/shared/child-process/retryable-process-exit-proof.test.ts b/src/shared/child-process/retryable-process-exit-proof.test.ts new file mode 100644 index 00000000000..a7bc5797645 --- /dev/null +++ b/src/shared/child-process/retryable-process-exit-proof.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +import { RetryableProcessExitProof } from './retryable-process-exit-proof' + +describe('RetryableProcessExitProof', () => { + it('shares a concurrent attempt and retains proven exit', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi.fn(async () => true) + + const first = proof.run(proveExit) + const concurrent = proof.run(proveExit) + + await expect(Promise.all([first, concurrent])).resolves.toEqual([true, true]) + await expect(proof.run(proveExit)).resolves.toBe(true) + expect(proveExit).toHaveBeenCalledOnce() + }) + + it('permits another attempt after exit was not proven', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + await expect(proof.run(proveExit)).resolves.toBe(false) + await expect(proof.run(proveExit)).resolves.toBe(true) + expect(proveExit).toHaveBeenCalledTimes(2) + }) + + it('permits another attempt after proof rejects', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi + .fn() + .mockRejectedValueOnce(new Error('probe failed')) + .mockResolvedValue(true) + + await expect(proof.run(proveExit)).rejects.toThrow('probe failed') + await expect(proof.run(proveExit)).resolves.toBe(true) + }) +}) diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/shared/child-process/retryable-process-exit-proof.ts new file mode 100644 index 00000000000..f8c3a89de31 --- /dev/null +++ b/src/shared/child-process/retryable-process-exit-proof.ts @@ -0,0 +1,26 @@ +export class RetryableProcessExitProof { + private inFlight: Promise | null = null + + run(proveExit: () => Promise): Promise { + if (this.inFlight) { + return this.inFlight + } + const attempt = proveExit() + this.inFlight = attempt + void attempt.then( + (proven) => { + if (!proven) { + this.clear(attempt) + } + }, + () => this.clear(attempt) + ) + return attempt + } + + private clear(attempt: Promise): void { + if (this.inFlight === attempt) { + this.inFlight = null + } + } +} diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index bd0507608a3..781afb30eee 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -2,6 +2,7 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, + type ChildProcessWithoutNullStreams, type SpawnOptions as NodeSpawnOptions } from 'node:child_process' import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line' @@ -148,9 +149,13 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso * `runProcess` handles that for you; here it cannot, because a blanket handler * would also defeat callers that track and remove their own listeners. */ -export function spawnProcess(spec: ProcessSpec): ChildProcess { +export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams { const resolved = resolveSpawn(spec, process.platform) - return nodeSpawn(resolved.file, [...resolved.args], resolved.options) + return nodeSpawn( + resolved.file, + [...resolved.args], + resolved.options + ) as ChildProcessWithoutNullStreams } /** diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index a501dfd6ec6..f7a05d8c3f1 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -125,6 +125,7 @@ export function buildDefaultSettings(args: { terminalLinkActionPopoverEnabled: true, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, + experimentalStructuredNativeChat: false, nativeChatSessionOptions: {}, openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 0ed2c72154c..5b746515548 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -205,6 +205,8 @@ export type GlobalSettings = { openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */ experimentalNativeChat?: boolean + /** Opt-in updated structured runtime; off keeps the existing PTY-backed native chat path. */ + experimentalStructuredNativeChat?: boolean /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 0e9e242af6e..1df82db4800 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -80,6 +80,35 @@ function toLines(value: unknown, maxLines: number): { lines: string[]; truncated return { lines: bounded, truncated } } +function patchTextFromToolInput(value: Record): string | null { + if (typeof value.patch === 'string') { + return value.patch + } + if (typeof value.diff === 'string') { + return value.diff + } + if (!Array.isArray(value.changes)) { + return null + } + const sections = value.changes.flatMap((entry) => { + if (typeof entry !== 'object' || entry === null) { + return [] + } + const change = entry as Record + if (typeof change.diff !== 'string') { + return [] + } + const path = typeof change.path === 'string' ? change.path : 'file' + const kind = + typeof change.kind === 'object' && change.kind !== null + ? (change.kind as Record) + : null + const nextPath = kind && typeof kind.move_path === 'string' ? kind.move_path : path + return [`--- ${path}\n+++ ${nextPath}\n${change.diff}`] + }) + return sections.length > 0 ? sections.join('\n') : null +} + export function diffFromToolCall( name: string, input: unknown, @@ -89,6 +118,10 @@ export function diffFromToolCall( return null } const value = input as Record + const patchText = patchTextFromToolInput(value) + if (patchText !== null) { + return diffFromText(patchText, maxLines) + } const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines) const newLines = toLines( value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text, diff --git a/src/shared/native-chat-provider-frame-summary.ts b/src/shared/native-chat-provider-frame-summary.ts new file mode 100644 index 00000000000..ef4e21563b9 --- /dev/null +++ b/src/shared/native-chat-provider-frame-summary.ts @@ -0,0 +1,11 @@ +import type { NativeChatBlock } from './native-chat-types' + +type ProviderFrameTextBlock = Extract + +export function nativeChatProviderFrameSummary(block: ProviderFrameTextBlock): string { + const frame = block.providerFrame + if (!frame) { + return block.text + } + return block.text === `${frame.provider} · ${frame.kind}` ? frame.kind : block.text +} diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index 21139d765f1..76556211ab0 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -15,6 +15,7 @@ import { } from './native-chat-session-option-state' export type NativeChatSessionOptionMode = 'draft' | 'live' +export type NativeChatLiveOptionTransport = 'catalog' | 'agent-session' function choiceWithCurrent( choices: readonly SessionOptionSelectChoice[], @@ -30,6 +31,7 @@ function choiceWithCurrent( function settableState(args: { mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } composedModelApply?: { midSession?: CatalogMidSessionApply } }): Pick { @@ -38,6 +40,9 @@ function settableState(args: { ? { settable: true } : { settable: false, disabledReason: 'available-after-session-start' } } + if (args.liveTransport === 'agent-session') { + return { settable: true } + } if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { return { settable: true } } @@ -50,9 +55,10 @@ function settableState(args: { function actionForApply( apply: { midSession?: CatalogMidSessionApply }, tracked: TrackedNativeChatSessionOption | undefined, - mode: NativeChatSessionOptionMode + mode: NativeChatSessionOptionMode, + liveTransport: NativeChatLiveOptionTransport ): SessionOptionDescriptor['action'] { - if (mode !== 'live') { + if (mode !== 'live' || liveTransport === 'agent-session') { return undefined } if (apply.midSession?.kind === 'agent-picker') { @@ -67,12 +73,13 @@ function optionDescriptor(args: { option: CatalogOption tracked: TrackedNativeChatSessionOption | undefined mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport modelIsCliDefault: boolean composedModelApply: AgentSessionOptionCatalog['modelApply'] }): SessionOptionDescriptor | null { - const { option, tracked, mode, modelIsCliDefault, composedModelApply } = args - const action = actionForApply(option.apply, tracked, mode) - const settable = settableState({ mode, apply: option.apply, composedModelApply }) + const { option, tracked, mode, liveTransport, modelIsCliDefault, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode, liveTransport) + const settable = settableState({ mode, liveTransport, apply: option.apply, composedModelApply }) // Why: the launch only emits `values[id] ?? defaultValue` alongside a model flag, so // a draft names this option's value exactly when a model was picked. Under the CLI's // own default no flag is sent at all, and the CLI's unstated choice is not ours to name. @@ -195,8 +202,9 @@ export function buildNativeChatSessionOptionSnapshot(args: { record: NativeChatSessionOptionRecord mode: NativeChatSessionOptionMode modelLabel: string + liveTransport?: NativeChatLiveOptionTransport }): SessionOptionDescriptor[] { - const { catalog, models, record, mode, modelLabel } = args + const { catalog, models, record, mode, modelLabel, liveTransport = 'catalog' } = args if (models.length === 0) { return [] } @@ -212,7 +220,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { const trackedModelId = typeof modelTracked?.value === 'string' ? modelTracked.value : null const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId) const effectiveModelId = trackedModelId ?? defaultModelId - const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode, liveTransport) const snapshot: SessionOptionDescriptor[] = [ { id: 'model', @@ -224,7 +232,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { choices: modelChoices }, valueSource: modelTracked?.source ?? (defaultModelId ? 'default' : 'unknown'), - ...settableState({ mode, apply: catalog.modelApply }), + ...settableState({ mode, liveTransport, apply: catalog.modelApply }), ...(modelAction ? { action: modelAction } : {}) } ] @@ -238,6 +246,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { option, tracked: trackedValues[option.id], mode, + liveTransport, modelIsCliDefault: effectiveModelId === defaultModelId, composedModelApply: catalog.modelApply }) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index a048e16da0e..9954521bed4 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -123,10 +123,27 @@ function normalizedToolFilePath(input: unknown): string | null { // target would label the row with the scan root and link to a folder. Costs the // link on a file-scoped search; a dead link on every other search is worse. const directory = isSearchToolInput(value) ? undefined : value.path - const path = value.file_path ?? value.filePath ?? directory ?? value.notebook_path + const path = + value.file_path ?? + value.filePath ?? + directory ?? + value.notebook_path ?? + firstPatchChangePath(value) return typeof path === 'string' && path.length > 0 ? path : null } +function firstPatchChangePath(value: Record): unknown { + if (!Array.isArray(value.changes)) { + return undefined + } + for (const change of value.changes) { + if (typeof change === 'object' && change !== null && typeof change.path === 'string') { + return change.path + } + } + return undefined +} + export function briefToolArg(input: unknown): string { const normalized = normalizeToolInput(input) if (normalized && typeof normalized === 'object') { diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 96d01f64181..370037c4bb2 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -31,6 +31,17 @@ export type NativeChatRole = (typeof NATIVE_CHAT_ROLES)[number] export type NativeChatTextBlock = { type: 'text' text: string + /** Optional structured detail for an otherwise ordinary fallback line. */ + providerFrame?: { + provider: string + kind: string + payload: { + head: string + byteLength: number + digest: string + truncated: boolean + } + } } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index a0297e5c33e..0bd70cba10d 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -171,7 +171,6 @@ const INVENTORY: readonly InventoryGroup[] = [ helper: 'resolveCommittedTitleAgentType', classification: 'identity-consumer', paths: [ - ['src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx', 3], ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4], ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2] ] diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bfd721ce672..2a52e153916 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -116,6 +116,15 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const +// Why: structured sessions are journal-backed, not PTY-backed, so a client that +// cannot read them must not see them at all — it would render an agent tab it +// can neither display nor drive. The host also refuses every agentSession.* +// method from a connection that does not advertise this. +export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: paired structured clients explicitly hold every visible session surface, allowing the host +// to stop provider children after the last surface closes without tying lifetime to a transport. +export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = + 'agent-session.structured.hold.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -207,6 +216,8 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts new file mode 100644 index 00000000000..40f60230218 --- /dev/null +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -0,0 +1,116 @@ +import type { AgentStatusEntry } from './agent-status-types' +import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' +import type { RuntimeBrowserPlacement } from './runtime-browser-placement' +import type { TerminalColorOverrides } from './terminal-color-overrides' +import type { TerminalLayoutSnapshot } from './terminal-tab-types' +import type { TuiAgent } from './tui-agent' + +export type RuntimeMobileSessionTerminalTab = { + type: 'terminal' + id: string + title: string + quickCommandLabel?: string | null + parentTabId: string + leafId: string + ptyId?: string | null + terminalTheme?: RuntimeMobileTerminalTheme + agentStatus?: AgentStatusEntry | null + /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ + turnCompletedAt?: number + launchAgent?: TuiAgent + startupCwd?: string + parentLayout?: TerminalLayoutSnapshot + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + launchDraft?: string + launchDraftCreatedAt?: number + isActive: boolean +} + +export type RuntimeMobileTerminalTheme = { + mode: 'dark' | 'light' + theme: TerminalColorOverrides +} + +export type RuntimeMobileSessionMarkdownTab = { + type: 'markdown' + id: string + title: string + filePath: string + relativePath: string + language: 'markdown' + mode: 'edit' | 'markdown-preview' + isDirty: boolean + isActive: boolean + sourceFileId: string + sourceFilePath: string + sourceRelativePath: string + documentVersion: string + color?: string | null + isPinned?: boolean +} + +export type RuntimeMobileSessionFileTab = { + type: 'file' + id: string + title: string + filePath: string + relativePath: string + language: string + mode?: 'edit' | 'diff' + diffSource?: 'staged' | 'unstaged' + isDirty: boolean + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionBrowserTab = { + type: 'browser' + id: string + title: string + browserWorkspaceId: string + browserPageId: string | null + browserProfileId?: string + executionHostKey?: string + placement?: RuntimeBrowserPlacement + url: string + loading: boolean + canGoBack: boolean + canGoForward: boolean + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionAgentTab = { + type: 'agent-session' + id: string + title: string + sessionId: string + agent: 'codex' + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionSnapshotTab = + | RuntimeMobileSessionTerminalTab + | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab + | RuntimeMobileSessionBrowserTab + | RuntimeMobileSessionAgentTab + +export type RuntimeMobileSessionTerminalClientTab = + | (RuntimeMobileSessionTerminalTab & { status: 'pending-handle'; terminal: null }) + | (RuntimeMobileSessionTerminalTab & { status: 'ready'; terminal: string }) + +export type RuntimeMobileSessionClientTab = + | RuntimeMobileSessionTerminalClientTab + | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab + | RuntimeMobileSessionBrowserTab + | RuntimeMobileSessionAgentTab diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 8bf41a9cdc5..1249870c548 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,17 +1,20 @@ -import type { AgentStatusEntry, AgentStatusOrchestrationContext } from './agent-status-types' -import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' +import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' -import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { RuntimeCapability } from './protocol-version' import type { RuntimeBrowserUnavailableReason, RuntimeDegradation } from './runtime-capability-degradation' import type { TabGroupLayoutNode } from './tab-types' -import type { TerminalColorOverrides } from './terminal-color-overrides' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from './terminal-tab-types' -import type { TuiAgent } from './tui-agent' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTerminalClientTab +} from './runtime-mobile-session-tab-contracts' + +export * from './runtime-mobile-session-tab-contracts' export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' @@ -160,103 +163,6 @@ export type RuntimeSyncWindowGraphResult = RuntimeStatus & { mobileSessionResyncWorktrees?: string[] } -export type RuntimeMobileSessionTerminalTab = { - type: 'terminal' - id: string - title: string - quickCommandLabel?: string | null - parentTabId: string - leafId: string - ptyId?: string | null - terminalTheme?: RuntimeMobileTerminalTheme - agentStatus?: AgentStatusEntry | null - /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ - turnCompletedAt?: number - launchAgent?: TuiAgent - startupCwd?: string - parentLayout?: TerminalLayoutSnapshot - color?: string | null - isPinned?: boolean - viewMode?: 'terminal' | 'chat' - launchDraft?: string - launchDraftCreatedAt?: number - isActive: boolean -} - -export type RuntimeMobileTerminalTheme = { - mode: 'dark' | 'light' - theme: TerminalColorOverrides -} - -export type RuntimeMobileSessionMarkdownTab = { - type: 'markdown' - id: string - title: string - filePath: string - relativePath: string - language: 'markdown' - mode: 'edit' | 'markdown-preview' - isDirty: boolean - isActive: boolean - sourceFileId: string - sourceFilePath: string - sourceRelativePath: string - documentVersion: string - color?: string | null - isPinned?: boolean -} - -export type RuntimeMobileSessionFileTab = { - type: 'file' - id: string - title: string - filePath: string - relativePath: string - language: string - mode?: 'edit' | 'diff' - diffSource?: 'staged' | 'unstaged' - isDirty: boolean - color?: string | null - isPinned?: boolean - isActive: boolean -} - -export type RuntimeMobileSessionBrowserTab = { - type: 'browser' - id: string - title: string - browserWorkspaceId: string - browserPageId: string | null - browserProfileId?: string - executionHostKey?: string - placement?: RuntimeBrowserPlacement - url: string - loading: boolean - canGoBack: boolean - canGoForward: boolean - loadError?: BrowserLoadError | null - certificateFailure?: BrowserCertificateFailure | null - color?: string | null - isPinned?: boolean - isActive: boolean -} - -export type RuntimeMobileSessionSnapshotTab = - | RuntimeMobileSessionTerminalTab - | RuntimeMobileSessionMarkdownTab - | RuntimeMobileSessionFileTab - | RuntimeMobileSessionBrowserTab - -export type RuntimeMobileSessionTerminalClientTab = - | (RuntimeMobileSessionTerminalTab & { status: 'pending-handle'; terminal: null }) - | (RuntimeMobileSessionTerminalTab & { status: 'ready'; terminal: string }) - -export type RuntimeMobileSessionClientTab = - | RuntimeMobileSessionTerminalClientTab - | RuntimeMobileSessionMarkdownTab - | RuntimeMobileSessionFileTab - | RuntimeMobileSessionBrowserTab - export type RuntimeMobileSessionTabGroup = { id: string activeTabId: string | null @@ -310,7 +216,7 @@ export type RuntimeMobileSessionTabsSnapshot = { snapshotVersion: number activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null + activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null tabs: RuntimeMobileSessionSnapshotTab[] @@ -323,7 +229,7 @@ export type RuntimeMobileSessionTabsResult = { navigationIntent?: 'follow' activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null + activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null tabs: RuntimeMobileSessionClientTab[] diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 8e889129949..d863fada9ba 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -202,6 +203,11 @@ export type RuntimeTerminalSend = { accepted: boolean bytesWritten: number refusedReason?: 'no-agent' | 'permission' + /** + * Present only when a durable agent-session lease refused the write. Additive and optional: an + * old client sees the `accepted: false` it already handles and ignores this field. + */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal } export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null @@ -255,6 +261,8 @@ export type RuntimeTerminalCreate = { warning?: string agentSessionDisposition?: 'created' | 'adopted' isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number } export type RuntimeTerminalSplit = { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6ca9dd9bf58..856268217aa 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -114,6 +114,7 @@ export type { RuntimeBrowserDriverState, RuntimeDesktopWindowStatus, RuntimeGraphStatus, + RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionClientTab, RuntimeMobileSessionCreateTerminalResult, diff --git a/src/shared/sha256.ts b/src/shared/sha256.ts new file mode 100644 index 00000000000..10350ddf7c8 --- /dev/null +++ b/src/shared/sha256.ts @@ -0,0 +1,80 @@ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +]) + +function rotateRight(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)) +} + +export function sha256(message: Uint8Array): Uint8Array { + const hash = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]) + const bitLength = message.length * 8 + const paddedLength = ((message.length + 8) >> 6) * 64 + 64 + const bytes = new Uint8Array(paddedLength) + bytes.set(message) + bytes[message.length] = 0x80 + const view = new DataView(bytes.buffer) + view.setUint32(paddedLength - 4, bitLength >>> 0, false) + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false) + + const words = new Uint32Array(64) + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let index = 0; index < 16; index += 1) { + words[index] = view.getUint32(offset + index * 4, false) + } + for (let index = 16; index < 64; index += 1) { + const s0 = + rotateRight(words[index - 15], 7) ^ + rotateRight(words[index - 15], 18) ^ + (words[index - 15] >>> 3) + const s1 = + rotateRight(words[index - 2], 17) ^ + rotateRight(words[index - 2], 19) ^ + (words[index - 2] >>> 10) + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) | 0 + } + + let [a, b, c, d, e, f, g, h] = hash + for (let index = 0; index < 64; index += 1) { + const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25) + const choice = (e & f) ^ (~e & g) + const first = (h + sigma1 + choice + K[index] + words[index]) | 0 + const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22) + const majority = (a & b) ^ (a & c) ^ (b & c) + const second = (sigma0 + majority) | 0 + h = g + g = f + f = e + e = (d + first) | 0 + d = c + c = b + b = a + a = (first + second) | 0 + } + + hash[0] = (hash[0] + a) | 0 + hash[1] = (hash[1] + b) | 0 + hash[2] = (hash[2] + c) | 0 + hash[3] = (hash[3] + d) | 0 + hash[4] = (hash[4] + e) | 0 + hash[5] = (hash[5] + f) | 0 + hash[6] = (hash[6] + g) | 0 + hash[7] = (hash[7] + h) | 0 + } + + const digest = new Uint8Array(32) + const digestView = new DataView(digest.buffer) + for (let index = 0; index < 8; index += 1) { + digestView.setUint32(index * 4, hash[index], false) + } + return digest +} diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts new file mode 100644 index 00000000000..51bc7fa0537 --- /dev/null +++ b/src/shared/structured-agent-session-coalescer.ts @@ -0,0 +1,81 @@ +import type { AgentSessionSubscribeEvent } from './agent-session-wire' + +export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 + +function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean { + return ( + event.type !== 'batch' || + event.batch.items.some((item) => item.body.kind !== 'message' || item.body.role !== 'assistant') + ) +} + +function mergeBatch( + left: Extract, + right: Extract +): Extract { + const items = new Map(left.batch.items.map((item) => [item.itemId, item])) + for (const item of right.batch.items) { + items.set(item.itemId, item) + } + const submissions = new Map( + left.batch.submissions.map((submission) => [submission.clientMessageId, submission]) + ) + for (const submission of right.batch.submissions) { + submissions.set(submission.clientMessageId, submission) + } + return { + type: 'batch', + sessionId: right.sessionId, + batch: { + cursor: right.batch.cursor, + items: [...items.values()], + removedItemIds: [...new Set([...left.batch.removedItemIds, ...right.batch.removedItemIds])], + submissions: [...submissions.values()] + }, + ...(right.fence !== undefined || left.fence !== undefined + ? { fence: right.fence ?? left.fence } + : {}), + ...(right.handoff || left.handoff ? { handoff: right.handoff ?? left.handoff } : {}) + } +} + +export function createStructuredAgentSessionEventCoalescer( + emit: (event: AgentSessionSubscribeEvent) => void, + delayMs = STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS +): { push: (event: AgentSessionSubscribeEvent) => void; flush: () => void; dispose: () => void } { + let pending: Extract | null = null + let timer: ReturnType | null = null + const flush = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + if (pending) { + const event = pending + pending = null + emit(event) + } + } + return { + push(event) { + if (bypassCoalescing(event)) { + flush() + emit(event) + return + } + if (event.type !== 'batch') { + return + } + pending = pending ? mergeBatch(pending, event) : event + timer ??= setTimeout(flush, delayMs) + }, + flush, + dispose() { + if (timer) { + clearTimeout(timer) + } + timer = null + pending = null + } + } +} diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts new file mode 100644 index 00000000000..420651aba5b --- /dev/null +++ b/src/shared/structured-agent-session-composer.ts @@ -0,0 +1,103 @@ +import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import type { AgentType } from './agent-status-types' +import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import type { SlashCommandSuggestion } from './native-chat-slash-commands' + +const EFFORT_COMMAND: SlashCommandSuggestion = { + name: 'effort', + description: 'Choose reasoning effort' +} + +export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [ + ...getVerifiedNativeChatCommands('codex').slice(0, 1), + EFFORT_COMMAND, + ...getVerifiedNativeChatCommands('codex').slice(1) +] + +export type StructuredAgentSessionComposerOptions = { + agent?: AgentType + snapshot: readonly SessionOptionDescriptor[] + invokeAction: (id: string) => Promise + setOption: (id: string, value: SessionOptionValue) => Promise +} + +export type StructuredAgentSessionCommandOutcome = { + handled: boolean + accepted: boolean + error: string | null +} + +function commandParts(text: string): { name: string; argument: string } | null { + if (!text.startsWith('/')) { + return null + } + const match = /^\/([^\s]+)(?:\s+(.*))?$/.exec(text.trimEnd()) + return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null +} + +function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] { + if (agent === 'codex') { + return STRUCTURED_AGENT_SESSION_SLASH_COMMANDS + } + return [...getVerifiedNativeChatCommands(agent), EFFORT_COMMAND] +} + +export function isStructuredAgentSessionComposerCommand( + text: string, + agent: AgentType = 'codex' +): boolean { + const command = commandParts(text) + return Boolean( + command && structuredSlashCommands(agent).some((entry) => entry.name === command.name) + ) +} + +function unavailable(name: string): StructuredAgentSessionCommandOutcome { + return { handled: true, accepted: true, error: `/${name} is not available in chat sessions.` } +} + +export async function dispatchStructuredAgentSessionComposerCommand( + text: string, + controller: StructuredAgentSessionComposerOptions +): Promise { + const command = commandParts(text) + if (!command || !isStructuredAgentSessionComposerCommand(text, controller.agent)) { + return { handled: false, accepted: false, error: null } + } + if (command.name !== 'model' && command.name !== 'effort') { + return unavailable(command.name) + } + const descriptor = controller.snapshot.find((entry) => entry.id === command.name) + if (!descriptor || descriptor.kind.type !== 'select') { + return { + handled: true, + accepted: true, + error: `${command.name === 'model' ? 'Models' : 'Reasoning effort'} are unavailable for this chat session.` + } + } + if (!command.argument) { + const opened = await controller.invokeAction(command.name) + return { + handled: true, + accepted: opened, + error: opened ? null : `Could not open the ${command.name} picker.` + } + } + const normalized = command.argument.toLowerCase() + const choice = descriptor.kind.choices.find( + (entry) => entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized + ) + if (!choice) { + return { + handled: true, + accepted: false, + error: `${command.argument} is not an available ${command.name} for this chat session.` + } + } + const applied = await controller.setOption(command.name, choice.value) + return { + handled: true, + accepted: applied, + error: applied ? null : `Could not apply ${command.name} ${choice.label}.` + } +} diff --git a/src/shared/structured-agent-session-mutation.test.ts b/src/shared/structured-agent-session-mutation.test.ts new file mode 100644 index 00000000000..ea92d8f0fd6 --- /dev/null +++ b/src/shared/structured-agent-session-mutation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { computeAgentSessionPayloadFingerprint } from './agent-session-mutation-envelope' + +describe('structured agent session client mutations', () => { + it('canonicalizes payload fields before hashing', () => { + const first = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { role: 'user', kind: 'message' }, omitted: undefined } + }) + const second = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', role: 'user' } } + }) + + expect(first).toBe(second) + expect(first).toMatch(/^[a-f0-9]{64}$/) + }) + + it('matches host code-unit ordering for mixed-case and non-ASCII keys', () => { + const input = { + method: 'agentSession.send', + sessionId: 'session-1', + fields: { a: 1, A: 2, é: 3, 中: 4 } + } + + expect(structuredAgentSessionPayloadFingerprint(input)).toBe( + computeAgentSessionPayloadFingerprint(input) + ) + }) +}) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts new file mode 100644 index 00000000000..ccc80475c93 --- /dev/null +++ b/src/shared/structured-agent-session-mutation.ts @@ -0,0 +1,39 @@ +import { sha256 } from './sha256' + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +export function structuredAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const bytes = sha256( + new TextEncoder().encode( + canonicalize({ method: input.method, sessionId: input.sessionId, fields: input.fields }) + ) + ) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function createStructuredAgentSessionOperationId( + randomUuid: () => string, + now: number = Date.now() +): string { + const timestamp = Math.trunc(now).toString() + const entropy = randomUuid().replaceAll('-', '').toLowerCase() + if (!/^\d{13}$/.test(timestamp) || !/^[0-9a-f]{32}$/.test(entropy)) { + throw new Error('Unable to create a durable operation id') + } + return `${timestamp}-${entropy}` +} diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts new file mode 100644 index 00000000000..72f817c7646 --- /dev/null +++ b/src/shared/structured-agent-session-options.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' +import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot' +import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' + +describe('structured agent session options', () => { + it('projects native Codex selects while bridge Codex keeps its agent picker', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { model: 'account-model', effort: 'medium' } + } + ) + + const structured = structuredAgentSessionOptionSnapshot(state) + expect(structured.map((descriptor) => descriptor.id)).toEqual(['model', 'effort']) + expect(structured[0]).toMatchObject({ + settable: true, + kind: { type: 'select', currentValue: 'account-model' } + }) + expect(structured[0]).not.toHaveProperty('action') + expect(structured[1]).toMatchObject({ + settable: true, + kind: { type: 'select', currentValue: 'medium' } + }) + + const bridgeRecord = createNativeChatSessionOptionRecord('codex') + bridgeRecord.model = { value: 'gpt-5.6-sol', source: 'reported' } + const bridge = buildNativeChatSessionOptionSnapshot({ + catalog: CODEX_SESSION_OPTION_CATALOG, + models: CODEX_SESSION_OPTION_CATALOG.models, + record: bridgeRecord, + mode: 'live', + modelLabel: 'Model' + }) + expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } }) + expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({ + action: { type: 'agent-picker' } + }) + }) + + it('uses provider-scoped models and retains the current unknown id', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: false, + efforts: [] + } + ], + current: { model: 'persisted-unknown' } + } + ) + const model = structuredAgentSessionOptionSnapshot(state)[0] + expect( + model.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] + ).toEqual(['account-model', 'persisted-unknown']) + expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown') + }) + + it('projects live options as directly settable descriptors', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { model: 'account-model', effort: 'medium' } + } + ) + + const snapshot = structuredAgentSessionOptionSnapshot(state) + expect(snapshot.map((descriptor) => descriptor.id)).toEqual(['model', 'effort']) + expect(snapshot.every((descriptor) => descriptor.settable)).toBe(true) + expect(snapshot.every((descriptor) => descriptor.action === undefined)).toBe(true) + }) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts new file mode 100644 index 00000000000..94f5f45351a --- /dev/null +++ b/src/shared/structured-agent-session-options.ts @@ -0,0 +1,146 @@ +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog' +import { + buildNativeChatSessionOptionSnapshot, + resolveEffectiveNativeChatModelId +} from './native-chat-session-option-snapshot' +import { + applyNativeChatReportedSessionOptions, + createNativeChatSessionOptionRecord, + setTrackedSessionOption, + type NativeChatSessionOptionRecord +} from './native-chat-session-option-state' +import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import type { AgentSessionOptionsResult } from './agent-session-wire' + +function effortOption(model: AgentSessionOptionsResult['models'][number]): CatalogOption | null { + if (model.efforts.length <= 1) { + return null + } + return { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: model.efforts, + defaultValue: model.defaultEffort ?? model.efforts[0]!.value + }, + apply: { midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } + } +} + +function discoveredModel(model: AgentSessionOptionsResult['models'][number]): CatalogModel { + const effort = effortOption(model) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(model.isDefault ? { isDefault: true } : {}), + options: effort ? [effort] : [] + } +} + +export function structuredAgentSessionOptionCatalog( + seed: AgentSessionOptionCatalog, + result: AgentSessionOptionsResult +): AgentSessionOptionCatalog { + const models: CatalogModel[] = result.models.map(discoveredModel) + if (!models.some((model) => model.id === result.current.model)) { + models.push({ + id: result.current.model, + label: result.current.model, + options: seed.unknownModelOptions ?? [] + }) + } + return { ...seed, models, defaultModelIsCliDefault: true } +} + +export type StructuredAgentSessionOptionState = { + catalog: AgentSessionOptionCatalog | null + record: NativeChatSessionOptionRecord + pendingId: string | null +} + +export function createStructuredAgentSessionOptionState( + agent = 'codex' +): StructuredAgentSessionOptionState { + return { catalog: null, record: createNativeChatSessionOptionRecord(agent), pendingId: null } +} + +export function applyStructuredAgentSessionOptions( + state: StructuredAgentSessionOptionState, + seed: AgentSessionOptionCatalog, + result: AgentSessionOptionsResult +): StructuredAgentSessionOptionState { + applyNativeChatReportedSessionOptions(state.record, { + model: result.current.model, + ...(result.current.effort ? { effort: result.current.effort } : {}) + }) + return { ...state, catalog: structuredAgentSessionOptionCatalog(seed, result) } +} + +export function structuredAgentSessionOptionSnapshot( + state: StructuredAgentSessionOptionState +): SessionOptionDescriptor[] { + if (!state.catalog) { + return [] + } + return buildNativeChatSessionOptionSnapshot({ + catalog: state.catalog, + models: state.catalog.models, + record: state.record, + mode: 'live', + modelLabel: 'Model', + liveTransport: 'agent-session' + }) +} + +export function canSetStructuredAgentSessionOption( + state: StructuredAgentSessionOptionState, + id: string, + value: SessionOptionValue +): boolean { + const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) + return Boolean( + state.catalog && + typeof value === 'string' && + state.pendingId === null && + descriptor?.kind.type === 'select' && + descriptor.kind.choices.some((choice) => choice.value === value) + ) +} + +export function commitStructuredAgentSessionOption( + state: StructuredAgentSessionOptionState, + id: string, + value: string +): StructuredAgentSessionOptionState { + if (!state.catalog) { + return state + } + const effectiveModel = resolveEffectiveNativeChatModelId( + state.catalog, + state.catalog.models, + state.record + ) + setTrackedSessionOption(state.record, id, value, 'dispatched', effectiveModel) + return { ...state, pendingId: null } +} + +export function commitStructuredAgentSessionOptionValues( + state: StructuredAgentSessionOptionState, + values: Readonly> +): StructuredAgentSessionOptionState { + let next = state + for (const id of ['model', 'effort']) { + const value = values[id] + if (value) { + next = commitStructuredAgentSessionOption(next, id, value) + } + } + return next +} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts new file mode 100644 index 00000000000..697bf0254b9 --- /dev/null +++ b/src/shared/structured-agent-session-outbox.ts @@ -0,0 +1,174 @@ +import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' +import type { AgentSessionWireRefusalCode } from './agent-session-wire' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' + +export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' + +export type StructuredAgentSessionOutboxEntry = { + clientMessageId: string + sessionId: string + body: AgentJournalMessageItem + previewUris: string[] + state: StructuredAgentSessionOutboxState + queuedAt: number + lastAttemptAt: number | null + retryAfterUnknownSubmittedAt: number | null +} + +export type StructuredAgentSessionAttachment = { + path: string + previewUri: string +} + +export function structuredAgentSessionSendBody( + text: string, + attachments: readonly StructuredAgentSessionAttachment[] +): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [ + ...(text.trim().length > 0 ? [{ type: 'text' as const, text: text.trimEnd() }] : []), + ...attachments.map((attachment) => ({ type: 'image-ref' as const, path: attachment.path })) + ] + } +} + +export function createStructuredAgentSessionOutboxEntry(args: { + clientMessageId: string + sessionId: string + text: string + attachments: readonly StructuredAgentSessionAttachment[] + queuedAt: number +}): StructuredAgentSessionOutboxEntry { + return { + clientMessageId: args.clientMessageId, + sessionId: args.sessionId, + body: structuredAgentSessionSendBody(args.text, args.attachments), + previewUris: args.attachments.map((attachment) => attachment.previewUri), + state: 'queued', + queuedAt: args.queuedAt, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } +} + +export function updateStructuredAgentSessionOutboxEntry( + entries: readonly StructuredAgentSessionOutboxEntry[], + id: string, + update: (entry: StructuredAgentSessionOutboxEntry) => StructuredAgentSessionOutboxEntry | null +): StructuredAgentSessionOutboxEntry[] { + return entries.flatMap((entry) => { + if (entry.clientMessageId !== id) { + return [entry] + } + const next = update(entry) + return next ? [next] : [] + }) +} + +export function requeueStructuredAgentSessionSendRefusal( + entry: StructuredAgentSessionOutboxEntry, + code: AgentSessionWireRefusalCode, + createOperationId: () => string +): StructuredAgentSessionOutboxEntry { + if (agentSessionRefusalOperationState('agentSession.send', code) !== 'settled-rejected') { + return { ...entry, state: 'queued' } + } + return { + ...entry, + clientMessageId: createOperationId(), + state: 'queued', + retryAfterUnknownSubmittedAt: null + } +} + +export function reconcileStructuredAgentSessionOutbox( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionOutboxEntry[] { + const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) + return entries.flatMap((entry) => { + const submission = settled.get(entry.clientMessageId) + if (submission?.dispatchState === 'accepted') { + return [] + } + if ( + submission?.dispatchState === 'unknown' && + entry.retryAfterUnknownSubmittedAt !== -1 && + entry.retryAfterUnknownSubmittedAt !== submission.submittedAt + ) { + return [{ ...entry, state: 'unconfirmed' as const }] + } + return [entry] + }) +} + +export function parseStructuredAgentSessionOutboxEntry( + value: unknown, + sessionId: string +): StructuredAgentSessionOutboxEntry | null { + if (typeof value !== 'object' || value === null) { + return null + } + const entry = value as Partial + const body = entry.body + if ( + entry.sessionId !== sessionId || + typeof entry.clientMessageId !== 'string' || + typeof entry.queuedAt !== 'number' || + !body || + body.kind !== 'message' || + body.role !== 'user' || + !Array.isArray(body.blocks) || + !Array.isArray(entry.previewUris) || + !entry.previewUris.every((uri) => typeof uri === 'string') || + !['queued', 'dispatching', 'unconfirmed'].includes(entry.state ?? '') + ) { + return null + } + return { + clientMessageId: entry.clientMessageId, + sessionId, + body, + previewUris: entry.previewUris, + state: entry.state as StructuredAgentSessionOutboxState, + queuedAt: entry.queuedAt, + lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null, + retryAfterUnknownSubmittedAt: + typeof entry.retryAfterUnknownSubmittedAt === 'number' + ? entry.retryAfterUnknownSubmittedAt + : null + } +} + +export function structuredAgentSessionSendRequest( + entry: StructuredAgentSessionOutboxEntry, + expectedRuntimeFence: number +): Record { + const fields = { body: entry.body } + return { + envelope: { + sessionId: entry.sessionId, + clientOperationId: entry.clientMessageId, + expectedRuntimeFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: entry.sessionId, + fields + }) + }, + ...(entry.retryAfterUnknownSubmittedAt !== null ? { retryUnknown: true } : {}), + ...fields + } +} + +export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed' + +export function classifyStructuredAgentSessionSendFailure( + error: unknown, + isDeliveryUnknown: (error: unknown) => boolean +): StructuredAgentSessionSendFailure { + return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed' +} diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts new file mode 100644 index 00000000000..bdd4b4c8f07 --- /dev/null +++ b/src/shared/structured-agent-session-projection.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { parsePaneKey } from './stable-pane-id' +import { + activeStructuredAgentSessionTurnId, + hasPersistedStructuredAgentSessionTurn, + projectStructuredItemToNativeChat, + projectStructuredAgentSessionStatus, + structuredAgentSessionPaneKey +} from './structured-agent-session-projection' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('structured agent session status projection', () => { + it('projects running, attention, and completed lifecycle states', () => { + const running = item('running', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const prompt = item('prompt', 2, { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) + const completed = item('completed', 3, { + kind: 'status', + text: 'Done', + turnLifecycle: { turnId: 'turn-1', state: 'completed' } + }) + + expect(activeStructuredAgentSessionTurnId([running])).toBe('turn-1') + expect(projectStructuredAgentSessionStatus([running])).toBe('working') + expect(projectStructuredAgentSessionStatus([running, prompt])).toBe('attention') + expect(activeStructuredAgentSessionTurnId([running, completed])).toBeNull() + expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle') + }) + + it('creates a deterministic pane identity for status stores', () => { + const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1') + + expect(structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')).toBe(paneKey) + expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' }) + }) + + it('requires a persisted provider conversation turn before TUI resume', () => { + const status = item('status', 1, { kind: 'status', text: 'Connected' }) + const user = item('user', 2, { kind: 'message', role: 'user', blocks: [] }) + + expect(hasPersistedStructuredAgentSessionTurn([])).toBe(false) + expect(hasPersistedStructuredAgentSessionTurn([status])).toBe(false) + expect(hasPersistedStructuredAgentSessionTurn([status, user])).toBe(true) + }) + + it('preserves provider-frame detail on the backward-compatible status line', () => { + const projected = projectStructuredItemToNativeChat( + item('frame', 1, { + kind: 'status', + text: 'codex · notification:new/event', + providerFrame: { + provider: 'codex', + kind: 'notification:new/event', + payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false } + } + }) + ) + + expect(projected?.blocks).toEqual([ + expect.objectContaining({ + type: 'text', + text: 'codex · notification:new/event', + providerFrame: expect.objectContaining({ kind: 'notification:new/event' }) + }) + ]) + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts new file mode 100644 index 00000000000..27a7cd2458f --- /dev/null +++ b/src/shared/structured-agent-session-projection.ts @@ -0,0 +1,154 @@ +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { sha256 } from './sha256' + +function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { + return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head +} + +function itemBlocks(item: AgentJournalRenderItem): { + role: NativeChatMessage['role'] + blocks: NativeChatBlock[] +} | null { + const body = item.body + if (body.kind === 'message') { + return { role: body.role, blocks: body.blocks } + } + if (body.kind === 'tool-call') { + return { + role: 'assistant', + blocks: [ + { type: 'tool-call', name: body.name, input: body.input }, + ...(body.output + ? [ + { + type: 'tool-result' as const, + output: boundedText(body.output), + isError: body.state === 'failed' + } + ] + : []) + ] + } + } + if (body.kind === 'diff') { + return { + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: body.path } }, + { type: 'tool-result', output: boundedText(body.patch) } + ] + } + } + if (body.kind === 'approval') { + if (body.resolution.state === 'pending') { + return null + } + return { + role: 'system', + blocks: [ + { + type: 'text', + text: `${body.title}\n${body.detail ?? ''}\n${body.resolution.state}`.trim() + } + ] + } + } + if (body.kind === 'question') { + if (body.resolution.state === 'pending') { + return null + } + const choices = body.options.map((option) => option.label).join(' · ') + return { + role: 'system', + blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }] + } + } + if (body.turnLifecycle) { + return null + } + return { + role: 'system', + blocks: [ + { + type: 'text', + text: body.text, + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) + } + ] + } +} + +export function projectStructuredItemsToNativeChat( + items: readonly AgentJournalRenderItem[] +): NativeChatMessage[] { + return items.flatMap((item) => { + const projected = itemBlocks(item) + return projected + ? [ + { + id: item.itemId, + role: projected.role, + blocks: projected.blocks, + timestamp: item.observedAt, + source: 'transcript' + } + ] + : [] + }) +} + +export function projectStructuredItemToNativeChat( + item: AgentJournalRenderItem +): NativeChatMessage | null { + return projectStructuredItemsToNativeChat([item])[0] ?? null +} + +export function activeStructuredAgentSessionTurnId( + items: readonly AgentJournalRenderItem[] +): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (body?.kind === 'status' && body.turnLifecycle) { + return body.turnLifecycle.state === 'running' ? body.turnLifecycle.turnId : null + } + } + return null +} + +export function hasPersistedStructuredAgentSessionTurn( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + item.body.kind === 'message' && (item.body.role === 'user' || item.body.role === 'assistant') + ) +} + +export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle' + +export function structuredAgentSessionTabId(sessionId: string): string { + return `structured-agent-session-${sessionId}` +} + +export function projectStructuredAgentSessionStatus( + items: readonly AgentJournalRenderItem[] +): StructuredAgentSessionProjectedStatus { + if ( + items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + ) { + return 'attention' + } + return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle' +} + +export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string { + const bytes = sha256(new TextEncoder().encode(sessionId)) + const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('') + const leaf = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}` + return `${tabId}:${leaf}` +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts new file mode 100644 index 00000000000..222db53a564 --- /dev/null +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionHistoryPage } from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from './structured-agent-session-reducer' + +function item(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] } + } +} + +function submission(index: number) { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted' as const, + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function hydrationPage( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [] +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: items[0] ? { epoch: 'epoch-a', sequence: oldest } : null, + newest: items.at(-1) ? { epoch: 'epoch-a', sequence: newest } : null, + nextCursor: { epoch: 'epoch-a', sequence: oldest } + }, + liveCursor: { epoch: 'epoch-a', sequence: newest }, + hasOlder: false, + hasNewer: false + } +} + +describe('structured agent session reducer', () => { + it('uses the bounded hydration page pagination boundary', () => { + const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage( + Array.from({ length: 84 }, (_, index) => item(`item-${index}`, index + 1)) + ) + } + }) + + expect(restored.items).toHaveLength(84) + expect(restored.hasOlder).toBe(false) + }) + + it('does not let a stale focus refresh replace newer streamed state', () => { + const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('streamed', 50)]) + } + }) + const afterRefresh = reduceStructuredAgentSession(streamed, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('stale', 40)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 40 }, + newest: { epoch: 'epoch-a', sequence: 40 }, + nextCursor: { epoch: 'epoch-a', sequence: 40 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 40 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(afterRefresh).toBe(streamed) + }) + + it('keeps paged-in older items when a focus refresh carries nothing new', () => { + const snapshot = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('newest', 50)]) + } + }) + const withOlder = reduceStructuredAgentSession(snapshot, { + type: 'older-page', + requestedEpoch: 'epoch-a', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'before', + items: [item('older', 10)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 10 }, + newest: { epoch: 'epoch-a', sequence: 10 }, + nextCursor: { epoch: 'epoch-a', sequence: 10 } + }, + hasOlder: false, + hasNewer: true + } + }) + const afterRefresh = reduceStructuredAgentSession(withOlder, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('newest', 50)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 50 }, + newest: { epoch: 'epoch-a', sequence: 50 }, + nextCursor: { epoch: 'epoch-a', sequence: 50 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 50 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(afterRefresh).toBe(withOlder) + expect(afterRefresh.items.map((entry) => entry.itemId)).toEqual(['older', 'newest']) + }) + + it('accepts a newer fence from an equal-cursor tail refresh', () => { + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('newest', 50)]) + } + }) + const page = { ...hydrationPage([item('newest', 50)]), fence: 2 } + + const refreshed = reduceStructuredAgentSession(initial, { type: 'tail-page', page }) + + expect(refreshed.fence).toBe(2) + expect(refreshed.items).toBe(initial.items) + }) + + it('keeps rapid-send submissions when a newer tail refresh contains only the last one', () => { + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage( + [item('first', 10)], + Array.from({ length: 8 }, (_, index) => submission(index)) + ) + } + }) + const refreshed = reduceStructuredAgentSession(initial, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('latest', 11)], + removedItemIds: [], + submissions: [submission(7)], + window: { + oldest: { epoch: 'epoch-a', sequence: 11 }, + newest: { epoch: 'epoch-a', sequence: 11 }, + nextCursor: { epoch: 'epoch-a', sequence: 11 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 11 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(refreshed.submissions.map((entry) => entry.clientMessageId)).toEqual( + Array.from({ length: 8 }, (_, index) => `client-${index}`) + ) + }) + + it('bounds retained submission identities across repeated tail refreshes', () => { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('first', 1)]) + } + }) + + for (let index = 0; index < 300; index += 1) { + state = reduceStructuredAgentSession(state, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item(`item-${index}`, index + 2)], + removedItemIds: [], + submissions: [submission(index)], + window: { + oldest: { epoch: 'epoch-a', sequence: index + 2 }, + newest: { epoch: 'epoch-a', sequence: index + 2 }, + nextCursor: { epoch: 'epoch-a', sequence: index + 2 } + }, + liveCursor: { epoch: 'epoch-a', sequence: index + 2 }, + hasOlder: true, + hasNewer: false + } + }) + } + + expect(state.submissions).toHaveLength(256) + expect(state.submissions[0]?.clientMessageId).toBe('client-44') + expect(state.submissions.at(-1)?.clientMessageId).toBe('client-299') + }) +}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts new file mode 100644 index 00000000000..24b500fc2b3 --- /dev/null +++ b/src/shared/structured-agent-session-reducer.ts @@ -0,0 +1,188 @@ +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from './agent-session-wire' + +export type StructuredAgentSessionState = { + epoch: string | null + cursor: AgentJournalCursor | null + fence: number | null + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + hasOlder: boolean + status: 'idle' | 'loading' | 'ready' | 'error' + error?: string + handoff: AgentSessionHandoffStatus | null +} + +export type StructuredAgentSessionAction = + | { type: 'loading' } + | { type: 'error'; message: string } + | { type: 'handoff'; handoff: AgentSessionHandoffStatus } + | { type: 'event'; event: AgentSessionSubscribeEvent } + | { type: 'tail-page'; page: AgentSessionHistoryPage } + | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage } + +export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { + epoch: null, + cursor: null, + fence: null, + items: [], + submissions: [], + hasOlder: false, + status: 'idle', + handoff: null +} + +const MAX_RETAINED_SUBMISSIONS = 256 + +function replacePage( + page: AgentSessionHistoryPage, + fence: number, + handoff?: AgentSessionHandoffStatus +): StructuredAgentSessionState { + return { + epoch: page.epoch, + cursor: page.liveCursor ?? page.window.nextCursor, + fence, + items: [...page.items].sort((left, right) => left.sequence - right.sequence), + submissions: page.submissions, + hasOlder: page.hasOlder, + status: 'ready', + handoff: handoff ?? null + } +} + +function mergeItems( + current: readonly AgentJournalRenderItem[], + incoming: readonly AgentJournalRenderItem[], + removedIds: readonly string[] +): AgentJournalRenderItem[] { + const removed = new Set(removedIds) + const byId = new Map( + current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) + ) + for (const item of incoming) { + const prior = byId.get(item.itemId) + if (!prior || item.revision >= prior.revision) { + byId.set(item.itemId, item) + } + } + return [...byId.values()].sort((left, right) => left.sequence - right.sequence) +} + +function mergeSubmissions( + current: readonly AgentJournalSubmission[], + incoming: readonly AgentJournalSubmission[] +): AgentJournalSubmission[] { + const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) + for (const submission of incoming) { + byId.set(submission.clientMessageId, submission) + } + return [...byId.values()] + .sort((left, right) => left.submittedAt - right.submittedAt) + .slice(-MAX_RETAINED_SUBMISSIONS) +} + +export function reduceStructuredAgentSession( + state: StructuredAgentSessionState, + action: StructuredAgentSessionAction +): StructuredAgentSessionState { + if (action.type === 'loading') { + return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'loading' } + } + if (action.type === 'error') { + return { ...state, status: 'error', error: action.message } + } + if (action.type === 'handoff') { + return { ...state, handoff: action.handoff } + } + if (action.type === 'tail-page') { + const pageCursor = action.page.liveCursor ?? action.page.window.newest + // An equal cursor means the page holds nothing the stream has not already + // delivered; replacing would throw away paged-in older items mid-scroll. + if ( + state.epoch === action.page.epoch && + state.cursor && + (!pageCursor || pageCursor.sequence <= state.cursor.sequence) + ) { + if ( + pageCursor?.sequence === state.cursor.sequence && + action.page.fence !== undefined && + action.page.fence !== state.fence + ) { + return { ...state, fence: action.page.fence, status: 'ready', error: undefined } + } + return state + } + const sameEpoch = state.epoch === action.page.epoch + return { + epoch: action.page.epoch, + cursor: action.page.liveCursor ?? null, + fence: action.page.fence ?? null, + items: action.page.items, + submissions: sameEpoch + ? mergeSubmissions(state.submissions, action.page.submissions) + : action.page.submissions, + hasOlder: action.page.hasOlder, + status: 'ready', + handoff: state.handoff + } + } + if (action.type === 'older-page') { + if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) { + return state + } + return { + ...state, + items: mergeItems(state.items, action.page.items, action.page.removedItemIds), + submissions: mergeSubmissions(state.submissions, action.page.submissions), + hasOlder: action.page.hasOlder + } + } + const event = action.event + if (event.type === 'end') { + return state + } + if (event.type === 'snapshot' || event.type === 'reset') { + return replacePage(event.page, event.fence, event.handoff) + } + if (state.epoch !== event.batch.cursor.epoch) { + return state + } + if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) { + return state + } + return { + ...state, + cursor: event.batch.cursor, + fence: event.fence ?? state.fence, + items: mergeItems(state.items, event.batch.items, event.batch.removedItemIds), + submissions: mergeSubmissions(state.submissions, event.batch.submissions), + status: 'ready', + error: undefined, + handoff: event.handoff ?? state.handoff + } +} + +export function oldestStructuredAgentSessionCursor( + state: StructuredAgentSessionState +): AgentJournalCursor | null { + const oldest = state.items[0] + return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null +} + +export function shouldAdvanceStructuredResumeCursor( + current: AgentJournalCursor | null, + incoming: AgentJournalCursor +): boolean { + return ( + current === null || (current.epoch === incoming.epoch && incoming.sequence >= current.sequence) + ) +} diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index 0b5f263ff1c..5f2b74d7127 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -1,4 +1,5 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { AgentType } from './agent-status-types' import type { ExecutionHostId } from './execution-host' // ─── Tab Group Layout ─────────────────────────────────────────────── @@ -22,16 +23,27 @@ export type TabContentType = | 'diff' | 'conflict-review' | 'check-details' + | 'agent-session' | 'browser' | 'simulator' -export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'simulator' +export type WorkspaceVisibleTabType = + | 'terminal' + | 'editor' + | 'agent-session' + | 'browser' + | 'simulator' export type CtrlTabOrderMode = 'mru' | 'sequential' // Why: many-to-one — every editor-family kind collapses to 'editor'. Never invert it by equality; // resolve the concrete tab and project forward instead. export function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType { - if (contentType === 'browser' || contentType === 'terminal' || contentType === 'simulator') { + if ( + contentType === 'agent-session' || + contentType === 'browser' || + contentType === 'terminal' || + contentType === 'simulator' + ) { return contentType } return 'editor' @@ -56,6 +68,10 @@ export type Tab = { createdAt: number isPreview?: boolean // preview tabs get replaced by next single-click open isPinned?: boolean // pinned tabs survive "close others" + /** Provider backing a structured agent-session tab. */ + agentSessionAgent?: AgentType + /** Structured session adopted from this terminal's Codex TUI. */ + structuredSessionId?: string /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 687749a5eee..00515bfa513 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -576,6 +576,15 @@ describe('workspace_create_failed schema', () => { }) describe('settings_changed schema', () => { + it('accepts structured native chat as a boolean adoption signal', () => { + expect( + eventSchemas.settings_changed.safeParse({ + setting_key: 'experimentalStructuredNativeChat', + value_kind: 'bool' + }).success + ).toBe(true) + }) + it('accepts whitelisted setting keys', () => { for (const key of SETTINGS_CHANGED_WHITELIST) { const parsed = eventSchemas.settings_changed.safeParse({ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0466ca79430..9253d5d075d 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', + 'experimentalStructuredNativeChat', 'experimentalActivity', 'experimentalAgentDashboardPopout', 'experimentalTerminalAttention', diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts new file mode 100644 index 00000000000..f4924b83c47 --- /dev/null +++ b/src/shared/tui-agent-resume-startup.ts @@ -0,0 +1,71 @@ +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata, + type ResumableTuiAgent +} from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import type { AgentStartupPlan } from './tui-agent-startup' +import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' + +export function buildAgentResumeStartupPlan(args: { + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell + agentArgs?: string | null + agentEnv?: Record | null + agentCommand?: string | null + ompResumeFilePath?: string | null + sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean + isRemote?: boolean +}): AgentStartupPlan | null { + const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath) + if (!argv) { + return null + } + const shell = resolveStartupShell(args.platform, args.shell) + const resolvedAgentCommand = args.agentCommand?.trim() + const baseCommand = resolvedAgentCommand + ? ({ + ok: true, + command: resolvedAgentCommand, + commandWithoutSessionOptions: resolvedAgentCommand, + appliedSessionOptions: {} + } as const) + : resolveAgentLaunchCommand({ + agent: args.agent, + cmdOverrides: args.cmdOverrides, + platform: args.platform, + shell, + agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, + sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs, + isRemote: args.isRemote + }) + if (!baseCommand.ok) { + return null + } + const launchConfig = buildSleepingAgentLaunchConfig({ + ...args, + agentCommand: baseCommand.commandWithoutSessionOptions + }) + const launchCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell) + const applied = baseCommand.appliedSessionOptions + return { + agent: args.agent, + launchCommand, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig, + ...(args.agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), + ...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}), + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + } +} diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts new file mode 100644 index 00000000000..bcf5f43750c --- /dev/null +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' + +// Hermes is the only agent whose launch Orca rewrites token by token — it owns the startup query, +// the TUI mode, and where an override's flags may sit relative to the chat subcommand. Its cases +// outgrew the general startup-plan file. + +describe('hermes startup plans', () => { + it('moves Hermes command override flags after the chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run privately', + cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, + agentArgs: '--yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + }) + + it.each([ + { + shell: 'powershell' as const, + override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', + expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" + }, + { + shell: 'cmd' as const, + override: 'C:\\Tools\\hermes.exe --tui', + expected: "& 'C:\\Tools\\hermes.exe' 'chat'" + } + ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: testCase.override }, + platform: 'win32', + shell: testCase.shell + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) + }) + + it('removes a configured duplicate chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, + agentArgs: '--provider copilot chat --yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--provider' 'copilot' '--yolo'") + }) + + it('preserves an option value named chat', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --profile chat --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") + }) + + it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'automation prompt', + cmdOverrides: { hermes: 'hermes --query override --cli' }, + agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/--query=/g)).toHaveLength(1) + expect(script).not.toContain('override') + expect(script).not.toContain("'--cli'") + expect(script.match(/'--tui'/g)).toHaveLength(1) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') + }) + + it('preserves wrapper tokens before the Hermes executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") + }) + + it('selects the final Hermes executable token in a wrapper', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'sudo' '-u' 'hermes' 'hermes' 'chat'" + ) + }) + + it('selects the wrapped executable when the wrapper and command both name Hermes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") + expect(script.match(/'chat'/g)).toHaveLength(1) + }) + + it('does not mistake a Hermes option value for a wrapped executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--resume' 'hermes'") + }) + + it('preserves POSIX environment-assignment command prefixes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" + ) + }) + + it('rejects a Hermes command override with no identifiable executable', () => { + expect( + buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'custom-agent --tui' }, + platform: 'linux' + }) + ).toBeNull() + }) + + it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'x'.repeat(24_000), + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toBeNull() + }) + + it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( + 'keeps a complex cmd Hermes query out of command text: %s', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + } + ) + + it('uses the Windows remote default shell for SSH Hermes queries', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run remotely', + cmdOverrides: {}, + platform: 'win32', + isRemote: true + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( + "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" + ) + }) + + it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '界'.repeat(50_000), + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toBeNull() + }) + + it('keeps empty Hermes launches on the interactive TUI command', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('hermes --tui') + expect(plan?.followupPrompt).toBeNull() + }) +}) diff --git a/src/shared/tui-agent-startup-script.test-fixture.ts b/src/shared/tui-agent-startup-script.test-fixture.ts new file mode 100644 index 00000000000..b76197edcaa --- /dev/null +++ b/src/shared/tui-agent-startup-script.test-fixture.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +// A startup plan's launch command carries the real script encoded for the target shell — octal +// escapes fed to `printf %b` on POSIX, UTF-16 base64 for PowerShell. Assertions want the script, +// so these decode it back. + +export function unwrapPosixShellScript(command: string | undefined): string { + const tokenized = tokenizeStartupCommand(command ?? '', 'posix') + expect(tokenized.ok).toBe(true) + const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' + const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] + if (!encoded) { + return wrapper + } + const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function unwrapPowerShellScript(command: string | undefined): string { + const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index 45d83900b86..a8c05f4e68b 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -140,15 +140,22 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) }) - it('never injects session options into resume commands', () => { + it('applies explicit session options to resume commands', () => { const plan = buildAgentResumeStartupPlan({ agent: 'codex', providerSession: { key: 'session_id', id: 'thread-1' }, cmdOverrides: {}, platform: 'linux', - sessionOptions: { model: 'gpt-5.5', effort: 'high' } + agentArgs: '-m gpt-5.6-sol -c model_reasoning_effort=medium', + sessionOptions: { model: 'gpt-5.5', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true }) - expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'") - expect(plan?.sessionOptions).toBeUndefined() + expect(plan?.launchCommand).toBe( + "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high' 'resume' 'thread-1'" + ) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.5', effort: 'high' }) }) }) diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 12c9f56e270..e5e26f46802 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -9,24 +9,10 @@ import { import { TUI_AGENT_CONFIG } from './tui-agent-config' import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' import { tokenizeStartupCommand } from './tui-agent-startup-shell' - -function unwrapPosixShellScript(command: string | undefined): string { - const tokenized = tokenizeStartupCommand(command ?? '', 'posix') - expect(tokenized.ok).toBe(true) - const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' - const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] - if (!encoded) { - return wrapper - } - const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) - return new TextDecoder().decode(new Uint8Array(bytes)) -} - -function unwrapPowerShellScript(command: string | undefined): string { - const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeDefined() - return Buffer.from(encoded!, 'base64').toString('utf16le') -} +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' describe('draft prefill teardown ordering (#14975)', () => { // Why pinned: the teardown mutates the calling shell, so it must reference @@ -212,224 +198,6 @@ describe('tui agent startup plans', () => { expect(tokens.ok && tokens.tokens.at(-1)).toMatch(/\\0[0-7]{3}/) }) - it('moves Hermes command override flags after the chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run privately', - cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, - agentArgs: '--yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') - }) - - it.each([ - { - shell: 'powershell' as const, - override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', - expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" - }, - { - shell: 'cmd' as const, - override: 'C:\\Tools\\hermes.exe --tui', - expected: "& 'C:\\Tools\\hermes.exe' 'chat'" - } - ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: testCase.override }, - platform: 'win32', - shell: testCase.shell - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) - }) - - it('removes a configured duplicate chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, - agentArgs: '--provider copilot chat --yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--provider' 'copilot' '--yolo'") - }) - - it('preserves an option value named chat', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --profile chat --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") - }) - - it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'automation prompt', - cmdOverrides: { hermes: 'hermes --query override --cli' }, - agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/--query=/g)).toHaveLength(1) - expect(script).not.toContain('override') - expect(script).not.toContain("'--cli'") - expect(script.match(/'--tui'/g)).toHaveLength(1) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') - }) - - it('preserves wrapper tokens before the Hermes executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") - }) - - it('selects the final Hermes executable token in a wrapper', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'sudo' '-u' 'hermes' 'hermes' 'chat'" - ) - }) - - it('selects the wrapped executable when the wrapper and command both name Hermes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") - expect(script.match(/'chat'/g)).toHaveLength(1) - }) - - it('does not mistake a Hermes option value for a wrapped executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--resume' 'hermes'") - }) - - it('preserves POSIX environment-assignment command prefixes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" - ) - }) - - it('rejects a Hermes command override with no identifiable executable', () => { - expect( - buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'custom-agent --tui' }, - platform: 'linux' - }) - ).toBeNull() - }) - - it('rejects Hermes queries that exceed the safe Windows environment limit', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'x'.repeat(24_000), - cmdOverrides: {}, - platform: 'win32' - }) - - expect(plan).toBeNull() - }) - - it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( - 'keeps a complex cmd Hermes query out of command text: %s', - (prompt) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt, - cmdOverrides: {}, - platform: 'win32', - shell: 'cmd' - }) - - expect(plan?.launchCommand).not.toContain(prompt) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) - } - ) - - it('uses the Windows remote default shell for SSH Hermes queries', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run remotely', - cmdOverrides: {}, - platform: 'win32', - isRemote: true - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( - "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" - ) - }) - - it('measures POSIX Hermes query limits in UTF-8 bytes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '界'.repeat(50_000), - cmdOverrides: {}, - platform: 'linux' - }) - - expect(plan).toBeNull() - }) - - it('keeps empty Hermes launches on the interactive TUI command', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '', - cmdOverrides: {}, - platform: 'linux', - allowEmptyPromptLaunch: true - }) - - expect(plan?.launchCommand).toBe('hermes --tui') - expect(plan?.followupPrompt).toBeNull() - }) - it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', @@ -614,6 +382,7 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).toBe("codex 'resume' 's1'") + expect(plan?.startupCommandDelivery).toBe('shell-ready') }) it('quotes Windows resume argv for cmd.exe when shell is cmd', () => { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 7b013c4ffd7..fba16776378 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,10 +1,5 @@ import { isShellProcess } from './agent-detection' -import { - getAgentResumeArgv, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentLaunchConfig -} from './agent-session-resume' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' import { clearEnvCommand, commandSeparator, @@ -20,7 +15,8 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' -import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' + +export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' export type AgentStartupPlan = { agent: TuiAgent @@ -185,54 +181,6 @@ export function buildAgentStartupPlan(args: { } } -export function buildAgentResumeStartupPlan(args: { - agent: ResumableTuiAgent - providerSession: AgentProviderSessionMetadata - cmdOverrides: Partial> - platform: NodeJS.Platform - shell?: AgentStartupShell - agentArgs?: string | null - agentEnv?: Record | null - agentCommand?: string | null - ompResumeFilePath?: string | null - sessionOptions?: Record - /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ - isRemote?: boolean -}): AgentStartupPlan | null { - const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath) - if (!argv) { - return null - } - const shell = resolveStartupShell(args.platform, args.shell) - const config = TUI_AGENT_CONFIG[args.agent] - const resolvedAgentCommand = args.agentCommand?.trim() - const baseCommand = resolvedAgentCommand - ? ({ ok: true, command: resolvedAgentCommand } as const) - : resolveAgentLaunchCommand({ - agent: args.agent, - cmdOverrides: args.cmdOverrides, - platform: args.platform, - shell, - agentArgs: args.agentArgs, - isRemote: args.isRemote - }) - if (!baseCommand.ok) { - return null - } - const launchConfig = buildSleepingAgentLaunchConfig({ - ...args, - agentCommand: baseCommand.command - }) - return { - agent: args.agent, - launchCommand: buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell), - expectedProcess: config.expectedProcess, - followupPrompt: null, - launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } -} - export type AgentDraftLaunchPlan = { agent: TuiAgent launchCommand: string diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 20d11ba5fc2..66e2ddcc514 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -547,6 +547,45 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves a structured agent session tab and its active projection', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'session-1', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'session-1', + entityId: 'session-1', + groupId: 'group1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent: 'codex', + structuredSessionId: 'codex-session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + activeTabTypeByWorktree: { wt: 'agent-session' } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt[0]).toMatchObject({ + contentType: 'agent-session', + agentSessionAgent: 'codex', + structuredSessionId: 'codex-session-1' + }) + expect(result.value.activeTabTypeByWorktree?.wt).toBe('agent-session') + } + }) + it('degrades an unknown viewMode to the safe default instead of failing parse', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2cc23106f9e..2ed368baedf 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -32,6 +32,10 @@ import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-cl import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record' import { persistedOpenFileSchema } from './workspace-session-editor-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { + tabContentTypeSchema, + workspaceVisibleTabTypeSchema +} from './workspace-session-tab-type-schema' import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -109,18 +113,6 @@ const terminalTabSchema = z.object({ // ─── Unified tab model ────────────────────────────────────────────── -const tabContentTypeSchema = z.enum([ - 'terminal', - 'editor', - 'diff', - 'conflict-review', - 'check-details', - 'browser', - 'simulator' -]) - -const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'simulator']) - const executionHostIdSchema = z.custom( (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) ) @@ -132,6 +124,10 @@ const tabSchema = z.object({ worktreeId: z.string(), executionHostId: executionHostIdSchema.optional(), contentType: tabContentTypeSchema, + agentSessionAgent: z.enum(['codex', 'claude']).optional().catch(undefined), + // Why: a structured terminal tab must recover its durable host session after + // restart; omitting this additive field silently routes it back through PTY. + structuredSessionId: z.string().min(1).optional().catch(undefined), label: z.string(), generatedLabel: z.string().nullable().optional(), aiVaultTitle: z diff --git a/src/shared/workspace-session-tab-type-schema.ts b/src/shared/workspace-session-tab-type-schema.ts new file mode 100644 index 00000000000..aee022ec6b4 --- /dev/null +++ b/src/shared/workspace-session-tab-type-schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const tabContentTypeSchema = z.enum([ + 'terminal', + 'editor', + 'diff', + 'conflict-review', + 'check-details', + 'agent-session', + 'browser', + 'simulator' +]) + +export const workspaceVisibleTabTypeSchema = z.enum([ + 'terminal', + 'editor', + 'agent-session', + 'browser', + 'simulator' +]) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts new file mode 100644 index 00000000000..093547102c6 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -0,0 +1,667 @@ +// Cross-version coverage for the structured agent-session surface, paired the same +// way the terminal wire harness is: current code against a real published release. +// +// Three skews matter here, and none can be checked from one build alone — an old +// client must not be shown a session it cannot render, a new client must find an +// old host's missing surface cleanly, and a client's cursor must survive the host +// process that minted it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { resolveBaselineReleaseRef } from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const SESSION = 'session-alpha' +const WORKSPACE = 'workspace-1' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const NOW = 1_800_000_000_000 + +/** Every method the structured surface publishes, paired with the host method it + * must reach — a gate that hides one method and leaks another is the bug. */ +const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ + { method: 'agentSession.createSupport', hostMethod: null }, + { method: 'agentSession.create', hostMethod: 'attach' }, + { method: 'agentSession.ensure', hostMethod: 'attach' }, + { method: 'agentSession.send', hostMethod: 'send' }, + { method: 'agentSession.cancel', hostMethod: 'cancel' }, + { method: 'agentSession.close', hostMethod: 'close' }, + { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' }, + { method: 'agentSession.options', hostMethod: 'readOptions' }, + { method: 'agentSession.hold', hostMethod: 'hold' }, + { method: 'agentSession.release', hostMethod: 'release' }, + { method: 'agentSession.history', hostMethod: 'history' }, + { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, + // Teardown runs through the runtime's subscription registry rather than the + // host, so its reply is the only signal that the gate opened. + { method: 'agentSession.unsubscribe', hostMethod: null } +] + +let baselineRef: string +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild +let operations = 0 + +beforeAll(async () => { + baselineRef = resolveBaselineReleaseRef() + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(baselineRef) +}, SUITE_TIMEOUT_MS) + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(args: { + method: string + fields: Record + fence: number | null +}): Record { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: args.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: args.method, + sessionId: SESSION, + fields: args.fields + }) + } +} + +function attachParams(fence: number | null): Record { + const params = { + envelope: { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params as unknown as AgentSessionAttachParams) + }) + } + } +} + +function createIntentParams(): Record { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields } +} + +function sendParams(text: string, fence: number): Record { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +} + +/** Schema-valid params per method; values only need to survive validation. */ +function paramsFor(method: string): unknown { + const fence = 1 + switch (method) { + case 'agentSession.createSupport': + return { worktree: `id:${WORKSPACE}`, agent: 'codex' } + case 'agentSession.create': + return createIntentParams() + case 'agentSession.ensure': + return attachParams(fence) + case 'agentSession.send': + return sendParams('hi', fence) + case 'agentSession.cancel': + return { + envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }), + turnId: 'turn-1' + } + case 'agentSession.respondToApproval': + case 'agentSession.respondToQuestion': { + const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.setOption': { + const fields = { key: 'model', value: 'gpt-5' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.history': + return { sessionId: SESSION, direction: 'tail' } + case 'agentSession.hold': + case 'agentSession.release': + return { sessionId: SESSION, holderId: 'surface-1' } + default: + return { sessionId: SESSION } + } +} + +function runtimeStub(): unknown { + const cleanups = new Map void>() + return { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => undefined, + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup), + cleanupSubscription: (id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }, + cleanupSubscriptionsByPrefix: (prefix: string) => { + for (const [id, cleanup] of cleanups) { + if (id.startsWith(prefix)) { + cleanup() + cleanups.delete(id) + } + } + } + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} + +describe('cross-version structured agent sessions', () => { + it( + 'skews current code against a real published release', + () => { + expect(baselineRef).toMatch(/^v?\d/) + expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/) + expect(baseline.revision).not.toBe(current.revision) + // The anti-vacuous oracle for the source scan: a scan that found nothing + // would make every "no structured method here" claim below meaningless. + expect(baseline.methodNames).toContain('terminal.create') + expect(current.methodNames).toContain('terminal.create') + }, + SUITE_TIMEOUT_MS + ) + + describe('a client that never asked for structured sessions', () => { + let hostCalls: Record> + + beforeEach(() => { + operations = 0 + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId: SESSION } })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + close: vi.fn(async () => undefined), + hold: vi.fn(async () => undefined), + release: vi.fn(() => undefined), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost) + }) + + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('is told the whole surface does not exist, and reaches no host method', async () => { + // The old build cannot name the capability, so its clients never send it. + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + } + for (const [name, spy] of Object.entries(hostCalls)) { + expect(spy, `${name} ran for a client without the capability`).not.toHaveBeenCalled() + } + }) + + it('is served the same calls once it advertises the capability', async () => { + for (const { method, hostMethod } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: [ + ...baseline.capabilities, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + }) + // A subscription that opens with nothing to say answers with no reply at + // all, so reaching the host is the signal that the gate opened. + if (hostMethod) { + expect(hostCalls[hostMethod], `${method} did not reach the host`).toHaveBeenCalled() + } else { + expect(replies[0], `${method} was refused`).toMatchObject({ ok: true }) + } + for (const reply of replies) { + expect(reply, `${method} was refused: ${JSON.stringify(reply)}`).toMatchObject({ + ok: true + }) + } + } + }) + }) + + describe('a new client against an old host', () => { + it('finds no structured method registered on the old build', () => { + expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) + expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( + STRUCTURED_CALLS.length + ) + }) + + it('can detect the absence during negotiation instead of by calling', () => { + expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive surface: bumping the protocol number would strand every paired + // device on this release rather than degrade one feature. + expect(current.protocolVersion).toBe(baseline.protocolVersion) + }) + + it('gets a clean method_not_found from the old dispatcher rather than silence', async () => { + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(baseline, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: current.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${method} on the old host`).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + } + }) + }) + + describe('an old client against a structured-owned AI Vault row', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: Record + let createMobileSessionTerminal: ReturnType + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'accepted' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-vault', + now: () => NOW + }) + setStructuredAgentSessionHost(host) + const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never) + expect(attached.ok).toBe(true) + createMobileSessionTerminal = vi.fn() + runtime = { + ...(runtimeStub() as Record), + listAiVaultSessions: vi.fn(async () => ({ + sessions: [ + { + id: `local:codex:${THREAD}:/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + executionHostId: 'local', + agent: 'codex', + sessionId: THREAD, + title: 'Owned thread', + cwd: '/repo', + branch: null, + model: null, + filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + codexHome: '/home/dev/.codex', + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-11T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume '${THREAD}'`, + subagent: null + } + ], + issues: [], + scannedAt: '2026-08-11T00:00:00.000Z' + })), + prepareAiVaultSessionResume: vi.fn(), + createMobileSessionTerminal + } + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('hides the row from the old client and annotates it for a capable client', async () => { + const oldReply = ( + await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }, + runtime + ) + )[0] + expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } }) + + const capableReply = ( + await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }, + runtime + ) + )[0] + expect(capableReply).toMatchObject({ + ok: true, + result: { + sessions: [ + { + structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE } + } + ] + } + }) + }) + + it('refuses cached prepare and both legacy launch deliveries before a second writer starts', async () => { + const params = { + agent: 'codex', + filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + codexHome: '/home/dev/.codex' + } + expect( + ( + await callBuild( + current, + 'aiVault.prepareSessionResume', + params, + { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + + expect( + ( + await callBuild( + current, + 'session.tabs.createTerminal', + { worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` }, + { clientKind: 'runtime', clientCapabilities: baseline.capabilities }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + expect( + ( + await callBuild( + current, + 'terminal.send', + { terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true }, + { clientKind: 'runtime', clientCapabilities: baseline.capabilities }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + expect(createMobileSessionTerminal).not.toHaveBeenCalled() + }) + }) + + describe('a cursor across a host restart', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: unknown + + /** Phase 2 owns provider processes; the adapter is the only stub here. */ + function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + } + + /** Reopens the store from disk and installs a fresh host over the same journal + * root — what a process restart actually leaves behind. */ + async function bootHost(generation: string): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation}`, + // The provider died with the host that spawned it, which is what makes + // the restarted host the legitimate next writer. + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + setStructuredAgentSessionHost(host) + return host + } + + type HostAnswer = { + ok: boolean + fence: number + cursor: { epoch: string; sequence: number } + refusal?: { code: string; currentFence?: number } + } + + /** Reattaching after a restart: the client's fence died with the previous + * host, and the refusal that says so is what hands it the live one. */ + async function reattach(staleFence: number): Promise { + const refused = await answer('agentSession.ensure', attachParams(staleFence)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + const currentFence = refused.refusal?.currentFence + expect(currentFence).toBeGreaterThan(staleFence) + const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) + expect(reattached).toMatchObject({ ok: true }) + return reattached + } + + async function call(method: string, params: unknown): Promise { + return callBuild( + current, + method, + params, + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientId: 'paired-device-1', + connectionId: 'connection-1' + }, + runtime + ) + } + + /** The host's own answer, which carries its refusals inside a successful RPC. */ + async function answer(method: string, params: unknown): Promise { + const reply = (await call(method, params))[0] + if (!reply?.ok) { + throw new Error(`${method} failed at the wire: ${JSON.stringify(reply?.error ?? reply)}`) + } + return reply.result as HostAnswer + } + + beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) + runtime = runtimeStub() + await bootHost('a') + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('resumes from the cursor the client held, with no snapshot and no replay', async () => { + const created = await answer('agentSession.create', createIntentParams()) + expect(created.ok).toBe(true) + const first = await answer('agentSession.send', sendParams('before restart', created.fence)) + expect(first.ok).toBe(true) + const held = first.cursor + + const restarted = await bootHost('b') + await restarted.restoreReadableSessions() + // Restart restores the session for READING. The chat the client still has open takes its + // hold, and that is what gives the session a provider child again. + await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) + const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(resumedFence).toBeGreaterThan(created.fence) + const second = await answer('agentSession.send', sendParams('after restart', resumedFence)) + expect(second.ok).toBe(true) + + const events = ( + await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + ).map((reply) => reply.result as AgentSessionSubscribeEvent) + expect(events.map((event) => event.type)).toEqual(['batch']) + const batch = events[0]?.type === 'batch' ? events[0].batch : null + const rendered = JSON.stringify(batch?.items ?? []) + expect(rendered).toContain('after restart') + // Everything the client already had stays out of the resume. + expect(rendered).not.toContain('before restart') + expect(batch?.cursor.epoch).toBe(held.epoch) + expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) + }) + + it('refuses a write still fenced to the host generation that died', async () => { + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b') + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + + expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + }) + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts index 1580f2fccc9..7a55bab6d50 100644 --- a/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts @@ -4,7 +4,11 @@ import { BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { BrowserTabCreateParams } from '../../../src/main/runtime/rpc/methods/browser-tab-create-schema' -import { materializeReleaseCheckout } from './release-checkout' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + type ReleaseCheckout +} from './release-checkout' type Schema = { parse: (value: unknown) => Record } @@ -19,12 +23,13 @@ const BASELINE_TAB_CREATE_SOURCES = [ ['browser-schemas.ts', 'TabCreate'] ] as const -async function importBaselineTabCreate(root: string): Promise { +async function importBaselineTabCreate(checkout: ReleaseCheckout): Promise { const attempted: string[] = [] for (const [file, exportName] of BASELINE_TAB_CREATE_SOURCES) { attempted.push(`${file}#${exportName}`) - const loaded = await import( - /* @vite-ignore */ `${root}/src/main/runtime/rpc/methods/${file}` + const loaded = await importReleaseCheckoutModule( + checkout, + `/src/main/runtime/rpc/methods/${file}` ).catch(() => null) const schema = loaded?.[exportName] as Schema | undefined if (schema?.parse) { @@ -32,7 +37,7 @@ async function importBaselineTabCreate(root: string): Promise { } } throw new Error( - `Baseline release at ${root} exposes no tab-create schema (tried ${attempted.join(', ')}).` + `Baseline release at ${checkout.root} exposes no tab-create schema (tried ${attempted.join(', ')}).` ) } @@ -52,11 +57,11 @@ let baselineProtocol: Record beforeAll(async () => { baselineRef = LEGACY_BROWSER_PLACEMENT_RELEASE_REF - const checkout = materializeReleaseCheckout(baselineRef) + const checkout = await materializeReleaseCheckout(baselineRef) baselineRevision = checkout.commit const [tabCreate, protocol] = await Promise.all([ - importBaselineTabCreate(checkout.root), - import(/* @vite-ignore */ `${checkout.root}/src/shared/protocol-version.ts`) + importBaselineTabCreate(checkout), + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') ]) baselineTabCreate = tabCreate baselineProtocol = protocol diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts new file mode 100644 index 00000000000..14674bfeefd --- /dev/null +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -0,0 +1,139 @@ +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, relative } from 'node:path' + +const CHECKOUT_PROCESS_TIMEOUT_MS = 45_000 +const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 + +// Why: the wire endpoints only need the runtime RPC host, the renderer client, and +// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. +const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] + +const ALIAS_SPECIFIER = + /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g + +function isRewritableSource(name: string): boolean { + return name.endsWith('.ts') || name.endsWith('.tsx') +} + +function isTestSource(name: string): boolean { + return /\.(test|bench|spec)\.(ts|tsx)$/.test(name) +} + +/** Keep renderer aliases inside the extracted release rather than the working tree. */ +async function rewriteRendererAliases(file: string, rendererRoot: string): Promise { + const source = await readFile(file, 'utf8') + if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) { + return false + } + const rewritten = source.replace( + ALIAS_SPECIFIER, + (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => { + const absolute = join(rendererRoot, target) + let relativePath = relative(dirname(file), absolute).split('\\').join('/') + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + return `${keyword}${quote}${relativePath}${quote}` + } + ) + if (rewritten === source) { + return false + } + await writeFile(file, rewritten) + return true +} + +async function prepareExtractedTree(root: string): Promise { + const rendererRoot = join(root, 'src', 'renderer', 'src') + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + await walk(full) + continue + } + if (!entry.isFile()) { + continue + } + // Why: stale specs must not enter repo-wide tool walks through the cache. + if (isTestSource(entry.name)) { + await rm(full) + continue + } + if (isRewritableSource(entry.name)) { + await rewriteRendererAliases(full, rendererRoot) + } + } + } + await walk(join(root, 'src')) +} + +function checkoutTarProgram(): string { + if (process.platform !== 'win32') { + return 'tar' + } + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows' + return join(systemRoot, 'System32', 'tar.exe') +} + +async function runCheckoutProcess( + repoRoot: string, + program: string, + args: string[], + deadline: number +): Promise { + // Kept lazy so plain Node 24 contention children never load Vite's TS graph. + const { runProcess } = await import('../../../src/shared/child-process/run-process') + const result = await runProcess({ + program, + args, + cwd: repoRoot, + timeoutMs: Math.max(1, deadline - Date.now()), + maxOutputBytes: CHECKOUT_MAX_OUTPUT_BYTES, + terminationBarrier: true + }) + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.timedOut + ? `timed out after ${CHECKOUT_PROCESS_TIMEOUT_MS}ms` + : result.stderr.trim() || `exited with ${result.code}` + throw new Error(`${program} ${args[0] ?? ''} ${detail}`) +} + +export async function extractReleaseCheckoutTree( + repoRoot: string, + staging: string, + commit: string +): Promise { + const archive = join(staging, '.release-checkout.tar') + const deadline = Date.now() + CHECKOUT_PROCESS_TIMEOUT_MS + try { + await runCheckoutProcess( + repoRoot, + 'git', + ['archive', '--format=tar', `--output=${archive}`, commit, '--', ...ARCHIVE_PATHS], + deadline + ) + await runCheckoutProcess( + repoRoot, + checkoutTarProgram(), + ['-xf', archive, '-C', staging], + deadline + ) + } finally { + await rm(archive, { force: true }) + } + await prepareExtractedTree(staging) +} + +export async function scavengeReleaseCheckoutStaging( + directory: string, + prefix: string +): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith(prefix)) { + await rm(join(directory, entry.name), { recursive: true, force: true }) + } + } +} diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 069375f4c18..eafee3802be 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -1,24 +1,18 @@ import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { lock } from 'proper-lockfile' import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' + extractReleaseCheckoutTree, + scavengeReleaseCheckoutStaging +} from './release-checkout-tree.ts' export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') -const CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') +const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') // Bump when extraction or the alias rewrite changes so cached trees are rebuilt. -const CHECKOUT_FORMAT = 1 - -// Why: the wire endpoints only need the runtime RPC host, the renderer client, and -// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. -const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] +const CHECKOUT_FORMAT = 3 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ @@ -34,6 +28,56 @@ export type ReleaseCheckout = { root: string } +export type MaterializeReleaseCheckoutOptions = { + cacheRoot?: string + /** Test-only lifecycle seams; production callers must use the defaults. */ + testHooks?: MaterializeReleaseCheckoutTestHooks +} + +export type CheckoutLockOptions = { + realpath: false + stale: number + update: number + retries: { + retries: number + factor: number + minTimeout: number + maxTimeout: number + randomize: boolean + } +} + +type CheckoutLockRelease = () => Promise +type AcquireCheckoutLock = ( + root: string, + options: CheckoutLockOptions +) => Promise + +export type CheckoutLifecycleContext = { + root: string + stagingPrefix: string +} + +export type CheckoutStagingContext = CheckoutLifecycleContext & { + staging: string +} + +export type MaterializeReleaseCheckoutTestHooks = { + lockOptions?: CheckoutLockOptions + acquireLock?: AcquireCheckoutLock + onLockAttempt?: (context: CheckoutLifecycleContext) => void + onLockAcquired?: (context: CheckoutLifecycleContext) => void | Promise + onStagingCreated?: (context: CheckoutStagingContext) => void | Promise + populateStaging?: (context: CheckoutStagingContext) => Promise +} + +const DEFAULT_LOCK_OPTIONS: CheckoutLockOptions = { + realpath: false, + stale: 60_000, + update: 10_000, + retries: { retries: 480, factor: 1, minTimeout: 250, maxTimeout: 250, randomize: true } +} + function git(args: string[]): string { return execFileSync('git', args, { cwd: REPO_ROOT, @@ -112,130 +156,142 @@ function resolveCommit(ref: string): string { } } -function isRewritableSource(name: string): boolean { - return name.endsWith('.ts') || name.endsWith('.tsx') -} - -function isTestSource(name: string): boolean { - return /\.(test|bench|spec)\.(ts|tsx)$/.test(name) -} - -const ALIAS_SPECIFIER = - /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g - -/** - * The extracted tree is imported directly, so `@/…` must resolve inside that tree. - * Vite's alias is global and points at the working tree, which would silently run - * current renderer code inside the "old" client. Rewrite to relative paths instead. - */ -function rewriteRendererAliases(file: string, rendererRoot: string): boolean { - const source = readFileSync(file, 'utf8') - if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) { - return false - } - const rewritten = source.replace( - ALIAS_SPECIFIER, - (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => { - const absolute = join(rendererRoot, target) - let relativePath = relative(dirname(file), absolute).split('\\').join('/') - if (!relativePath.startsWith('.')) { - relativePath = `./${relativePath}` - } - return `${keyword}${quote}${relativePath}${quote}` - } - ) - if (rewritten === source) { - return false - } - writeFileSync(file, rewritten) - return true -} - -function prepareExtractedTree(root: string): { rewritten: number; pruned: number } { - const rendererRoot = join(root, 'src', 'renderer', 'src') - let rewritten = 0 - let pruned = 0 - const walk = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const full = join(directory, entry.name) - if (entry.isDirectory()) { - walk(full) - continue - } - if (!entry.isFile()) { - continue - } - // Why: the old tree is imported, never collected. Dropping its tests keeps the - // cache small and keeps stale specs out of every repo-wide tool's file walk. - if (isTestSource(entry.name)) { - rmSync(full) - pruned++ - continue - } - if (isRewritableSource(entry.name) && rewriteRendererAliases(full, rendererRoot)) { - rewritten++ - } - } - } - walk(join(root, 'src')) - return { rewritten, pruned } -} - type CheckoutStamp = { commit: string; format: number } -function readStamp(root: string): CheckoutStamp | null { +async function readStamp(root: string): Promise { try { - return JSON.parse(readFileSync(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp + return JSON.parse(await readFile(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp } catch { return null } } +async function checkoutMatches(root: string, commit: string): Promise { + const stamp = await readStamp(root) + return stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT +} + +async function assertCheckoutWireSurface(root: string, ref: string): Promise { + try { + await access(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'), constants.F_OK) + } catch { + throw new Error( + `Cross-version checkout for ${ref} is missing the terminal stream protocol; ` + + 'the wire surface moved and the harness needs updating.' + ) + } +} + +function checkoutModulePath(checkout: ReleaseCheckout, rootRelativePath: string): string { + const fromRoot = rootRelativePath.replace(/^[/\\]+/, '') + const absolute = resolve(checkout.root, fromRoot) + const fromCheckout = relative(checkout.root, absolute) + if ( + !fromRoot || + fromCheckout === '..' || + fromCheckout.startsWith(`..${sep}`) || + isAbsolute(fromCheckout) + ) { + throw new Error( + `Cross-version module path must stay inside the release checkout: ${rootRelativePath}` + ) + } + return absolute.split('\\').join('/') +} + +/** + * Import a source module with `/src/...` anchored to the extracted release root. + * + * The specifier handed to `importModule` is a raw absolute forward-slash path, + * never a `file://` URL: CI vite-node resolves URL specifiers as root-relative + * ids and fails with `ERR_MODULE_NOT_FOUND` (run 33049571360). `importModule` + * is injectable only so tests can pin that contract deterministically. + */ +export function importReleaseCheckoutModule( + checkout: ReleaseCheckout, + rootRelativePath: string, + importModule: (specifier: string) => Promise> = (specifier) => + import(/* @vite-ignore */ specifier) as Promise> +): Promise> { + return importModule(checkoutModulePath(checkout, rootRelativePath)) +} + /** * Extract `src/` at `ref` into a cached, gitignored checkout the test can import. * Cached by resolved commit, so a moved tag or a bumped rewrite format re-extracts. */ -export function materializeReleaseCheckout(ref: string): ReleaseCheckout { +export async function materializeReleaseCheckout( + ref: string, + options: MaterializeReleaseCheckoutOptions = {} +): Promise { const commit = resolveCommit(ref) const label = ref.replace(/[^A-Za-z0-9._-]/g, '_') - const root = join(CACHE_ROOT, label) - const stamp = readStamp(root) - if (stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT) { + const cacheRoot = options.cacheRoot ?? DEFAULT_CACHE_ROOT + const root = join(cacheRoot, label, `${commit}-format-${CHECKOUT_FORMAT}`) + if (await checkoutMatches(root, commit)) { return { ref, commit, label, root } } - mkdirSync(CACHE_ROOT, { recursive: true }) - const staging = join(CACHE_ROOT, `.staging-${label}-${process.pid}`) - rmSync(staging, { recursive: true, force: true }) - mkdirSync(staging, { recursive: true }) + const stagingPrefix = `.staging-${commit}-format-${CHECKOUT_FORMAT}-` + const lifecycleContext = { root, stagingPrefix } + const hooks = options.testHooks + const lockOptions = hooks?.lockOptions ?? DEFAULT_LOCK_OPTIONS + const acquireLock: AcquireCheckoutLock = + hooks?.acquireLock ?? ((target, value) => lock(target, value)) + await mkdir(dirname(root), { recursive: true }) + let releaseLock: CheckoutLockRelease | undefined try { - // `git archive | tar -x` keeps the extraction independent of the working tree, - // so an injected violation in the working tree cannot leak into the old side. - execFileSync( - 'sh', - ['-c', `git archive ${commit} ${ARCHIVE_PATHS.join(' ')} | tar -x -C "${staging}"`], - { cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] } - ) - prepareExtractedTree(staging) - writeFileSync( + const acquiring = acquireLock(root, lockOptions) + try { + hooks?.onLockAttempt?.(lifecycleContext) + } catch (error) { + await acquiring.then( + async (release) => release(), + () => undefined + ) + throw error + } + releaseLock = await acquiring + } catch (error) { + if (await checkoutMatches(root, commit)) { + return { ref, commit, label, root } + } + throw new Error(`Cross-version harness could not lock ${ref} (${commit}): ${String(error)}`) + } + + let staging: string | undefined + try { + await hooks?.onLockAcquired?.(lifecycleContext) + if (await checkoutMatches(root, commit)) { + return { ref, commit, label, root } + } + await scavengeReleaseCheckoutStaging(dirname(root), stagingPrefix) + staging = await mkdtemp(join(dirname(root), stagingPrefix)) + const stagingContext = { ...lifecycleContext, staging } + await hooks?.onStagingCreated?.(stagingContext) + await (hooks?.populateStaging + ? hooks.populateStaging(stagingContext) + : extractReleaseCheckoutTree(REPO_ROOT, staging, commit)) + await assertCheckoutWireSurface(staging, ref) + await writeFile( join(staging, 'checkout-stamp.json'), `${JSON.stringify({ commit, format: CHECKOUT_FORMAT } satisfies CheckoutStamp, null, 2)}\n` ) - rmSync(root, { recursive: true, force: true }) - renameSync(staging, root) + await rm(root, { recursive: true, force: true }) + await rename(staging, root) + staging = undefined } catch (error) { - rmSync(staging, { recursive: true, force: true }) - if (readStamp(root)?.commit === commit) { + if (await checkoutMatches(root, commit)) { return { ref, commit, label, root } } throw new Error(`Cross-version harness failed to extract ${ref} (${commit}): ${String(error)}`) + } finally { + if (staging) { + await rm(staging, { recursive: true, force: true }) + } + await releaseLock() } - if (!existsSync(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'))) { - throw new Error( - `Cross-version checkout for ${ref} is missing the terminal stream protocol; ` + - 'the wire surface moved and the harness needs updating.' - ) - } + await assertCheckoutWireSurface(root, ref) return { ref, commit, label, root } } diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts new file mode 100644 index 00000000000..7057a38babd --- /dev/null +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -0,0 +1,510 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { lock } from 'proper-lockfile' +import { afterEach, describe, expect, it } from 'vitest' +import { forceTerminateProcessTree } from '../../../src/shared/child-process/process-tree-termination' +import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + REPO_ROOT, + type CheckoutLockOptions, + type CheckoutStagingContext, + type ReleaseCheckout +} from './release-checkout' +const temporaryRoots: string[] = [] + +const COMPRESSED_LOCK_OPTIONS: CheckoutLockOptions = { + realpath: false, + stale: 2_500, + update: 1_000, + retries: { retries: 200, factor: 1, minTimeout: 50, maxTimeout: 50, randomize: false } +} + +function temporaryCacheRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-cross-version-checkout-')) + temporaryRoots.push(root) + return root +} + +function git(args: string[]): string { + return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim() +} + +function syntheticCheckout(): ReleaseCheckout { + // Why realpath: vite-node reports module urls through macOS's /var -> /private/var + // symlink, so provenance assertions need the resolved form. + const root = realpathSync(temporaryCacheRoot()) + return { ref: 'v0.0.0-synthetic', commit: 'f'.repeat(40), label: 'v0.0.0-synthetic', root } +} + +function waitForCondition( + description: string, + condition: () => boolean, + timeoutMs: number +): Promise { + const startedAt = Date.now() + return new Promise((resolvePoll, rejectPoll) => { + const poll = (): void => { + if (condition()) { + resolvePoll() + return + } + if (Date.now() - startedAt > timeoutMs) { + rejectPoll(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)) + return + } + setTimeout(poll, 25) + } + poll() + }) +} + +function waitForFile(path: string, timeoutMs: number): Promise { + return waitForCondition(path, () => existsSync(path), timeoutMs) +} + +async function populateMinimalStaging({ staging }: CheckoutStagingContext): Promise { + const shared = join(staging, 'src', 'shared') + mkdirSync(shared, { recursive: true }) + writeFileSync(join(shared, 'terminal-stream-protocol.ts'), 'export const synthetic = true\n') +} + +type MaterializerChildConfig = { + cacheRoot: string + ref: string + resultPath?: string + attemptMarker?: string + acquiredMarker?: string + stagingMarker?: string + proceedPath?: string + ablateLock?: boolean + populateStaging?: boolean + hangAfterStaging?: boolean + lockOptions?: CheckoutLockOptions +} + +type ObservedMaterializerChild = { + child: ReturnType + exited: Promise + output: () => string +} + +function startMaterializerChild( + scratch: string, + name: string, + config: MaterializerChildConfig +): ObservedMaterializerChild { + const script = join(scratch, `${name}.mjs`) + const harnessUrl = new URL('./release-checkout.ts', import.meta.url).href + writeFileSync( + script, + [ + `const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')`, + `const { join } = await import('node:path')`, + `const harness = await import(${JSON.stringify(harnessUrl)})`, + `const config = ${JSON.stringify(config)}`, + `const waitForPath = async (path) => {`, + ` const startedAt = Date.now()`, + ` while (!existsSync(path)) {`, + ` if (Date.now() - startedAt > 30000) throw new Error('timed out waiting for ' + path)`, + ` await new Promise((resolve) => setTimeout(resolve, 10))`, + ` }`, + `}`, + `const hooks = { lockOptions: config.lockOptions }`, + `if (config.ablateLock) hooks.acquireLock = async () => async () => {}`, + `if (config.attemptMarker) hooks.onLockAttempt = () => writeFileSync(config.attemptMarker, '')`, + `if (config.acquiredMarker) hooks.onLockAcquired = () => writeFileSync(config.acquiredMarker, '')`, + `if (config.stagingMarker) {`, + ` hooks.onStagingCreated = async ({ root, staging }) => {`, + ` writeFileSync(config.stagingMarker, JSON.stringify({ root, staging }))`, + ` if (config.hangAfterStaging) await new Promise(() => setInterval(() => {}, 1000))`, + ` if (config.proceedPath) await waitForPath(config.proceedPath)`, + ` }`, + `}`, + `if (config.populateStaging) {`, + ` hooks.populateStaging = async ({ staging }) => {`, + ` const shared = join(staging, 'src', 'shared')`, + ` mkdirSync(shared, { recursive: true })`, + ` writeFileSync(join(shared, 'terminal-stream-protocol.ts'), 'export const synthetic = true\\n')`, + ` }`, + `}`, + `try {`, + ` const checkout = await harness.materializeReleaseCheckout(config.ref, { cacheRoot: config.cacheRoot, testHooks: hooks })`, + ` if (config.resultPath) writeFileSync(config.resultPath, JSON.stringify({ root: checkout.root }))`, + `} catch (error) {`, + ` if (config.resultPath) writeFileSync(config.resultPath, JSON.stringify({ error: String(error) }))`, + ` process.exitCode = 1`, + `}`, + '' + ].join('\n') + ) + + const child = spawnProcess({ + program: process.execPath, + args: [script], + cwd: REPO_ROOT, + env: { ...process.env, NODE_OPTIONS: '' }, + terminationBarrier: true + }) + let childOutput = '' + child.stdout.on('data', (chunk) => { + childOutput += String(chunk) + }) + child.stderr.on('data', (chunk) => { + childOutput += String(chunk) + }) + const exited = new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('close', resolveExit) + }) + return { child, exited, output: () => childOutput } +} + +async function stopMaterializerChild(observed: ObservedMaterializerChild): Promise { + if (observed.child.exitCode === null && observed.child.signalCode === null) { + await forceTerminateProcessTree(observed.child) + } + await observed.exited.catch(() => null) +} + +async function runContentionPhase( + published: ReleaseCheckout, + scratch: string, + phase: string, + ablateLock: boolean +): Promise { + const sentinel = join(published.root, `in-use-sentinel-${phase}.mjs`) + const aside = join(scratch, `published-aside-${phase}`) + const attemptMarker = join(scratch, `rival-attempted-${phase}`) + const acquiredMarker = join(scratch, `rival-acquired-${phase}`) + const stagingMarker = join(scratch, `rival-staging-${phase}`) + const proceedPath = join(scratch, `rival-proceed-${phase}`) + const resultPath = join(scratch, `rival-result-${phase}.json`) + writeFileSync(sentinel, "export const sentinel = 'published-tree'\n") + renameSync(published.root, aside) + const releaseLock = await lock(published.root, { realpath: false, stale: 60_000 }) + let released = false + let rival: ObservedMaterializerChild | undefined + const releaseOnce = async (): Promise => { + if (!released) { + released = true + await releaseLock() + } + } + + try { + rival = startMaterializerChild(scratch, `rival-${phase}`, { + cacheRoot: join(published.root, '..', '..'), + ref: published.ref, + resultPath, + attemptMarker, + acquiredMarker, + ...(ablateLock ? { ablateLock, stagingMarker, proceedPath, populateStaging: true } : {}) + }) + // onLockAttempt runs only after the rival's first stamp miss and invocation + // of the actual lock function; no elapsed-time guess stands in for contention. + await waitForFile(ablateLock ? stagingMarker : attemptMarker, 30_000) + if (!ablateLock) { + expect(existsSync(acquiredMarker), rival.output()).toBe(false) + } + + renameSync(aside, published.root) + const consuming = importReleaseCheckoutModule(published, `/in-use-sentinel-${phase}.mjs`).then( + (value) => value, + (error: unknown) => error + ) + if (ablateLock) { + // The staging marker is after the second stamp miss. Publishing before this + // acknowledgement would let the ablation pass without exercising deletion. + writeFileSync(proceedPath, '') + } + await releaseOnce() + + const exitCode = await rival.exited + const result = JSON.parse(readFileSync(resultPath, 'utf8')) as Record + expect(result, rival.output()).toEqual({ root: published.root }) + expect(exitCode, rival.output()).toBe(0) + expect(existsSync(acquiredMarker), rival.output()).toBe(true) + const consumerResult = await consuming + if (!ablateLock) { + expect(consumerResult).toMatchObject({ sentinel: 'published-tree' }) + } + return existsSync(sentinel) + } finally { + if (rival) { + await stopMaterializerChild(rival) + } + if (existsSync(aside) && !existsSync(published.root)) { + renameSync(aside, published.root) + } + await releaseOnce() + } +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('release checkout materialization', () => { + it('single-flights concurrent consumers of one release identity', async () => { + const cacheRoot = temporaryCacheRoot() + const checkouts = await Promise.all([ + materializeReleaseCheckout('v1.4.190', { cacheRoot }), + materializeReleaseCheckout('v1.4.190', { cacheRoot }), + materializeReleaseCheckout('v1.4.190', { cacheRoot }) + ]) + + expect(new Set(checkouts.map(({ root }) => root))).toHaveLength(1) + expect(relative(cacheRoot, checkouts[0]!.root)).not.toMatch(/^\.\./) + }) + + it('loads a baseline module whose source imports another checkout-root file', async () => { + const cacheRoot = temporaryCacheRoot() + const checkout = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) + const protocol = await importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') + + expect(protocol.REMOTE_SERVER_UPDATE_CAPABILITY).toBe('updater.remote-control.v1') + expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./) + }) + + it('keeps an import live while another colliding release label materializes', async () => { + const merge = git(['rev-list', '--merges', '-1', 'HEAD']) + const firstRef = `${merge}~2` + const secondRef = `${merge}^2` + expect(git(['rev-parse', `${firstRef}^{commit}`])).not.toBe( + git(['rev-parse', `${secondRef}^{commit}`]) + ) + + const cacheRoot = temporaryCacheRoot() + const first = await materializeReleaseCheckout(firstRef, { cacheRoot }) + const dependency = join(first.root, 'delayed-dependency.mjs') + const entry = join(first.root, 'delayed-entry.mjs') + writeFileSync(dependency, "export const loaded = 'first-release'\n") + writeFileSync( + entry, + 'await new Promise((resolve) => setTimeout(resolve, 100))\n' + + "export const loaded = (await import('./delayed-dependency.mjs')).loaded\n" + ) + + const loading = importReleaseCheckoutModule(first, '/delayed-entry.mjs') + const second = await materializeReleaseCheckout(secondRef, { cacheRoot }) + + await expect(loading).resolves.toMatchObject({ loaded: 'first-release' }) + expect(first.root).not.toBe(second.root) + }) + + it('causally single-flights a rival process before publishing an in-use checkout', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const published = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) + + await expect(runContentionPhase(published, scratch, 'locked', false)).resolves.toBe(true) + // In the same causally acknowledged interleaving, a no-lock materializer + // deletes the newly published tree. This makes the lock assertion non-vacuous. + await expect(runContentionPhase(published, scratch, 'ablated', true)).resolves.toBe(false) + }, 120_000) + + it('keeps the real lock live while publication work exceeds its stale interval', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const attemptMarker = join(scratch, 'heartbeat-rival-attempted') + const acquiredMarker = join(scratch, 'heartbeat-rival-acquired') + const resultPath = join(scratch, 'heartbeat-rival-result.json') + let releaseWork!: () => void + const workGate = new Promise((resolveWork) => { + releaseWork = resolveWork + }) + let acknowledgeStaging!: (context: CheckoutStagingContext) => void + const stagingReady = new Promise((resolveStaging) => { + acknowledgeStaging = resolveStaging + }) + const publisher = materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + lockOptions: COMPRESSED_LOCK_OPTIONS, + onStagingCreated: async (context) => { + acknowledgeStaging(context) + await workGate + }, + populateStaging: populateMinimalStaging + } + }) + const active = await stagingReady + const rival = startMaterializerChild(scratch, 'heartbeat-rival', { + cacheRoot, + ref: 'v1.4.190', + resultPath, + attemptMarker, + acquiredMarker, + lockOptions: COMPRESSED_LOCK_OPTIONS + }) + + try { + await waitForFile(attemptMarker, 30_000) + const blockedAt = Date.now() + const mtimes = new Set() + await waitForCondition( + 'multiple lock heartbeats beyond the stale interval', + () => { + if (existsSync(`${active.root}.lock`)) { + mtimes.add(statSync(`${active.root}.lock`).mtimeMs) + } + return Date.now() - blockedAt > COMPRESSED_LOCK_OPTIONS.stale + 250 && mtimes.size >= 3 + }, + 15_000 + ) + expect(existsSync(acquiredMarker), rival.output()).toBe(false) + expect(existsSync(active.staging)).toBe(true) + + releaseWork() + await publisher + const exitCode = await rival.exited + expect(exitCode, rival.output()).toBe(0) + expect(existsSync(acquiredMarker), rival.output()).toBe(true) + expect(JSON.parse(readFileSync(resultPath, 'utf8')), rival.output()).toEqual({ + root: active.root + }) + } finally { + releaseWork() + await stopMaterializerChild(rival) + await publisher.catch(() => undefined) + } + }, 30_000) + + it('recovers a crashed lock owner and scavenges only its orphaned staging trees', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const stagingMarker = join(scratch, 'crashed-staging') + const crashed = startMaterializerChild(scratch, 'crashing-publisher', { + cacheRoot, + ref: 'v1.4.190', + stagingMarker, + hangAfterStaging: true, + lockOptions: COMPRESSED_LOCK_OPTIONS + }) + + try { + await waitForFile(stagingMarker, 30_000) + const crashedContext = JSON.parse(readFileSync(stagingMarker, 'utf8')) as { + root: string + staging: string + } + const lockPath = `${crashedContext.root}.lock` + expect(existsSync(crashedContext.staging)).toBe(true) + expect(existsSync(lockPath)).toBe(true) + await forceTerminateProcessTree(crashed.child) + const crashExit = await crashed.exited + expect(crashExit, crashed.output()).not.toBe(0) + expect(existsSync(lockPath)).toBe(true) + + const unrelated = join(crashedContext.staging, '..', '.staging-unrelated-live-owner') + mkdirSync(unrelated) + const recovered = await materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + lockOptions: COMPRESSED_LOCK_OPTIONS, + populateStaging: populateMinimalStaging + } + }) + + expect(existsSync(crashedContext.staging)).toBe(false) + expect(existsSync(unrelated)).toBe(true) + expect(existsSync(join(recovered.root, 'src', 'shared', 'terminal-stream-protocol.ts'))).toBe( + true + ) + } finally { + await stopMaterializerChild(crashed) + } + }, 30_000) + + it('never stamps an incomplete tree produced through the injectable test seam', async () => { + const cacheRoot = temporaryCacheRoot() + await expect( + materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { populateStaging: async () => undefined } + }) + ).rejects.toThrow(/missing the terminal stream protocol/) + + let populated = 0 + const recovered = await materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + populateStaging: async (context) => { + populated++ + await populateMinimalStaging(context) + } + } + }) + expect(populated).toBe(1) + expect(existsSync(join(recovered.root, 'src', 'shared', 'terminal-stream-protocol.ts'))).toBe( + true + ) + }) +}) + +describe('release checkout module importer', () => { + it('hands the importer a raw absolute forward-slash specifier, never a file URL', async () => { + const checkout = syntheticCheckout() + const captured: string[] = [] + const capture = (specifier: string): Promise> => { + captured.push(specifier) + return Promise.resolve({}) + } + + await importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts', capture) + await importReleaseCheckoutModule(checkout, '\\src\\shared\\protocol-version.ts', capture) + + const normalizedRoot = checkout.root.split('\\').join('/') + expect(captured).toEqual([ + `${normalizedRoot}/src/main/runtime/rpc/dispatcher.ts`, + `${normalizedRoot}/src/shared/protocol-version.ts` + ]) + for (const specifier of captured) { + expect(specifier).not.toMatch(/^file:/) + expect(specifier).not.toContain('\\') + } + }) + + it('refuses module paths that escape the checkout root', () => { + const checkout = syntheticCheckout() + const escape = /stay inside the release checkout/ + expect(() => importReleaseCheckoutModule(checkout, '/src/../../escape.ts')).toThrow(escape) + expect(() => importReleaseCheckoutModule(checkout, '..')).toThrow(escape) + expect(() => importReleaseCheckoutModule(checkout, '')).toThrow(escape) + }) + + it('anchors root-relative modules to the checkout root, never the working tree', async () => { + const checkout = syntheticCheckout() + mkdirSync(join(checkout.root, 'src'), { recursive: true }) + writeFileSync( + join(checkout.root, 'src', 'provenance-probe.mjs'), + 'export const moduleUrl = import.meta.url\n' + ) + + const probe = await importReleaseCheckoutModule(checkout, '/src/provenance-probe.mjs') + expect(String(probe.moduleUrl)).toContain(checkout.root.split('\\').join('/')) + + // The working tree has this module and the synthetic checkout does not: + // resolving it would mean a root-relative specifier silently ran current + // code as the "old" side — the exact poison this harness exists to prevent. + await expect( + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') + ).rejects.toThrow() + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts new file mode 100644 index 00000000000..420efcbd994 --- /dev/null +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -0,0 +1,155 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + REPO_ROOT, + type ReleaseCheckout +} from './release-checkout' + +/** + * The two things that decide whether a structured agent session exists for a given + * pairing: the capability strings a build can name, and the RPC methods it + * registers. Both are read per build, so "the old side does not have it" is a fact + * about a real release rather than a hand-written list. + */ + +export const WORKING_TREE = 'working-tree' as const + +export type RpcReply = { + id: string + ok: boolean + streaming?: true + result?: unknown + error?: { code: string; message: string } +} + +export type RpcClientIdentity = { + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly string[] + connectionId?: string + clientId?: string +} + +export type AgentSessionDispatcher = { + dispatchStreaming: ( + request: { id: string; authToken: string; method: string; params?: unknown }, + reply: (message: string) => void, + options?: RpcClientIdentity + ) => Promise +} + +export type AgentSessionWireBuild = { + /** Human label used in test names and failure messages. */ + label: string + /** `working-tree` for current code, otherwise the resolved release commit. */ + revision: string + /** Capability strings this build defines. A peer cannot advertise — nor a client + * ask for — a string its own source never names. */ + capabilities: readonly string[] + protocolVersion: number + /** RPC method names the build registers, read from source. */ + methodNames: readonly string[] + /** A dispatcher carrying a method set this build really ships, so an + * unknown-method answer is about the method and not an empty registry. */ + createDispatcher: (runtime: unknown) => AgentSessionDispatcher +} + +type DispatcherModule = { + RpcDispatcher: new (options: { runtime: unknown; methods: unknown[] }) => AgentSessionDispatcher +} + +// A dotted literal in a `name:` position. Deliberately loose: over-matching only +// makes "this build registers no agentSession method" a stronger claim. +const METHOD_NAME = /\bname:\s*'([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)'/g + +/** + * Method names declared under `runtime/rpc/methods`, scanned rather than imported: + * a released build's method manifest reaches Electron, which cannot load here. + */ +function scanMethodNames(root: string): string[] { + const names = new Set() + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + for (const match of readFileSync(full, 'utf8').matchAll(METHOD_NAME)) { + names.add(match[1]!) + } + } + } + } + walk(join(root, 'src', 'main', 'runtime', 'rpc', 'methods')) + return [...names].sort() +} + +function capabilityStrings(module: Record): readonly string[] { + const declared = module.RUNTIME_CAPABILITIES + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error('Cross-version harness found no RUNTIME_CAPABILITIES to compare') + } + return declared as readonly string[] +} + +async function loadWorkingTreeBuild(): Promise { + const [protocol, dispatcher, structured, aiVault, sessionTabs, terminal] = await Promise.all([ + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/rpc/dispatcher'), + import('../../../src/main/runtime/rpc/methods/structured-agent-session'), + import('../../../src/main/runtime/rpc/methods/ai-vault'), + import('../../../src/main/runtime/rpc/methods/session-tabs'), + import('../../../src/main/runtime/rpc/methods/terminal') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: WORKING_TREE, + revision: WORKING_TREE, + capabilities: capabilityStrings(protocol as unknown as Record), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, + methodNames: scanMethodNames(REPO_ROOT), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: [ + ...(structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[]), + ...(aiVault.AI_VAULT_METHODS as unknown[]), + ...(sessionTabs.SESSION_TAB_METHODS as unknown[]), + ...(terminal.TERMINAL_METHODS as unknown[]) + ] + }) + } +} + +async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const [protocol, dispatcher, terminalMethods] = await Promise.all([ + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/methods/terminal.ts') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: checkout.ref, + revision: checkout.commit, + capabilities: capabilityStrings(protocol), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION as number, + methodNames: scanMethodNames(checkout.root), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: terminalMethods.TERMINAL_METHODS as unknown[] + }) + } +} + +/** + * Load the structured-session wire surface for one build. `WORKING_TREE` imports + * current source; any other value is a git ref extracted into a cached checkout. + */ +export async function loadAgentSessionWireBuild(ref: string): Promise { + if (ref === WORKING_TREE) { + return loadWorkingTreeBuild() + } + return loadReleaseBuild(await materializeReleaseCheckout(ref)) +} diff --git a/tests/e2e/cross-version-wire/versioned-terminal-wire.ts b/tests/e2e/cross-version-wire/versioned-terminal-wire.ts index 6a0fd0f467b..08e721900d6 100644 --- a/tests/e2e/cross-version-wire/versioned-terminal-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-terminal-wire.ts @@ -1,4 +1,8 @@ -import { materializeReleaseCheckout, type ReleaseCheckout } from './release-checkout' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + type ReleaseCheckout +} from './release-checkout' /** * Structural views of the three modules that make up the remote terminal wire. @@ -112,19 +116,15 @@ async function loadWorkingTreeBuild(): Promise { } } -// Why @vite-ignore: the checkout is created at run time, so Vite cannot glob it at -// transform time. Vite-node still resolves and transforms the target on demand. -function importFromCheckout(specifier: string): Promise> { - return import(/* @vite-ignore */ specifier) as Promise> -} - async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { - const base = `${checkout.root}/src` const [codec, dispatcher, terminalMethods, client] = await Promise.all([ - importFromCheckout(`${base}/shared/terminal-stream-protocol.ts`), - importFromCheckout(`${base}/main/runtime/rpc/dispatcher.ts`), - importFromCheckout(`${base}/main/runtime/rpc/methods/terminal.ts`), - importFromCheckout(`${base}/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts`) + importReleaseCheckoutModule(checkout, '/src/shared/terminal-stream-protocol.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/methods/terminal.ts'), + importReleaseCheckoutModule( + checkout, + '/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + ) ]) return { label: checkout.ref, @@ -147,5 +147,5 @@ export async function loadTerminalWireBuild(ref: string): Promise { const root = join('workspace', 'orca') const mainPath = join(root, 'out', 'main', 'index.js') - expect(getOrcaElectronLaunchArgs(mainPath, true)).toEqual([root]) + const args = getOrcaElectronLaunchArgs(mainPath, true) + expect(args.at(-1)).toBe(root) + if (process.platform === 'darwin') { + expect(args.slice(0, -1)).toEqual(['--password-store=basic', '--use-mock-keychain']) + } expect(getOrcaElectronLaunchArgs(mainPath, false).at(-1)).toBe(root) }) }) From 446110810c63687c73274920f60d279526d41f36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:07:56 -0700 Subject: [PATCH 11/12] Bump mobile app.json to 0.0.47 (#17102) * Bump mobile app.json to 0.0.47 * Bump Android versionCode to 15 above shipped 14 --- mobile/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 6b6d43f8f0e..d5a420cc74b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.44", + "version": "0.0.47", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 13 + "versionCode": 15 }, "plugins": [ "expo-router", From 4065d053cf942c1879379ebd8f1b50dda5568ff8 Mon Sep 17 00:00:00 2001 From: hwantage <82494320+hwantage@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:05:29 +0900 Subject: [PATCH 12/12] feat(i18n): localize onboarding checklist steps to Korean (#17108) - Add `feature-wall-setup-checklist-localized-copy.ts` using `createLocalizedCatalog` for dynamic step copy lookup. - Bind localized step name and description in `FeatureWallSetupChecklist.tsx`. - Add 16 localization keys in `en.json` and verified Korean translations in `ko.json`. - Add unit tests in `feature-wall-setup-checklist-localized-copy.test.ts`. - Resolves English fallback for all 8 onboarding checklist steps under Korean locale. --- .../FeatureWallSetupChecklist.tsx | 9 +- ...all-setup-checklist-localized-copy.test.ts | 26 +++++ ...ure-wall-setup-checklist-localized-copy.ts | 99 +++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 24 +++++ src/renderer/src/i18n/locales/ko.json | 24 +++++ 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts create mode 100644 src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx index 0fcea197023..b113f0cfba9 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -27,6 +27,8 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { getLocalizedFeatureWallSetupChecklistCopy } from './feature-wall-setup-checklist-localized-copy' + type FeatureWallSetupChecklistLayout = 'modal' | 'embedded' type FeatureWallSetupChecklistProps = { @@ -49,6 +51,7 @@ function SetupStepRow(props: { }): React.JSX.Element { const { step, done, active, ordinal, onSelect, layout } = props const isEmbedded = layout === 'embedded' + const localizedStepCopy = getLocalizedFeatureWallSetupChecklistCopy(step) return ( @@ -325,7 +328,7 @@ export function FeatureWallSetupChecklist(
- {activeStep.name} + {getLocalizedFeatureWallSetupChecklistCopy(activeStep).name}
- {activeStep.description} + {getLocalizedFeatureWallSetupChecklistCopy(activeStep).description}

{/* Action lives under the caption, not after the grid, so it sits just below the copy instead of being pushed down by the taller visual. */} diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts new file mode 100644 index 00000000000..b4206933cdf --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { FEATURE_WALL_SETUP_STEPS } from '../../../../shared/feature-wall-setup-steps' +import { getLocalizedFeatureWallSetupChecklistCopy } from './feature-wall-setup-checklist-localized-copy' +import ko from '../../i18n/locales/ko.json' +import en from '../../i18n/locales/en.json' + +describe('feature-wall-setup-checklist-localized-copy', () => { + it('returns non-empty localized name and description for all setup checklist steps', () => { + for (const step of FEATURE_WALL_SETUP_STEPS) { + const localized = getLocalizedFeatureWallSetupChecklistCopy(step) + expect(localized.name).toBeTruthy() + expect(localized.description).toBeTruthy() + } + }) + + it('has valid Korean and English catalog entries for all setup checklist steps', () => { + const enKeys = en.auto.components.feature.wall.feature.wall.setup.checklist.localized.copy + const koKeys = ko.auto.components.feature.wall.feature.wall.setup.checklist.localized.copy + expect(Object.keys(enKeys).length).toBe(16) + expect(Object.keys(koKeys).length).toBe(16) + for (const [hash, enVal] of Object.entries(enKeys)) { + expect(typeof enVal).toBe('string') + expect((koKeys as Record)[hash]).toBeTruthy() + } + }) +}) diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts new file mode 100644 index 00000000000..316011b4a0f --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts @@ -0,0 +1,99 @@ +import type { + FeatureWallSetupStep, + FeatureWallSetupStepId +} from '../../../../shared/feature-wall-setup-steps' +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' + +type LocalizedFeatureWallSetupChecklistCopy = Pick + +const getLocalizedFeatureWallSetupChecklistCopyById = createLocalizedCatalog( + (): Record => ({ + 'two-worktrees': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.ec0a363633', + 'Multi-task' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.62bac8f43c', + 'Work in 2 different worktrees at once. Each one is isolated (even in the same project). Perfect for working on 2 features at once.' + ) + }, + browser: { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.908898c3ee', + "Use Orca's browser" + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.43781563c3', + 'Browse your web app without leaving Orca. Grab any element and send its exact source and styles to an agent with one click.' + ) + }, + notifications: { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.29aa2c2077', + 'Turn on notifications' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.71bd9a8c95', + 'Know the moment an agent finishes, needs attention, or gets blocked.' + ) + }, + 'default-agent': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.46db810da8', + 'Choose your default agent' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.b8e5bae17f', + 'Start new work faster with your preferred agent already selected.' + ) + }, + 'agent-capabilities': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.fee5557b02', + 'Enable Orca CLI' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.7bcb4097fa', + 'Register the Orca shell command and install agent skills for browser, computer, and orchestration workflows.' + ) + }, + 'task-sources': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.ad342dd4c6', + 'Connect integrations' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.06fe30fdb0', + 'Start an agent from a task in one click and keep PR status in view.' + ) + }, + 'setup-script': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.eddc532e58', + 'Automate workspace setup' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.56049b74c2', + 'Run install and setup commands automatically so every new worktree is ready for agents.' + ) + }, + 'add-two-repos': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.2cf795433b', + 'Start work in multiple repos' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.42525ba8a4', + 'Bring your key repos into Orca so you can start agent work without hunting for folders.' + ) + } + }) +) + +export function getLocalizedFeatureWallSetupChecklistCopy( + step: FeatureWallSetupStep +): LocalizedFeatureWallSetupChecklistCopy { + return getLocalizedFeatureWallSetupChecklistCopyById()[step.id] +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c8f8d7bdf11..23f03820b50 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13845,6 +13845,30 @@ }, "feature": { "wall": { + "setup": { + "checklist": { + "localized": { + "copy": { + "ec0a363633": "Multi-task", + "62bac8f43c": "Work in 2 different worktrees at once. Each one is isolated (even in the same project). Perfect for working on 2 features at once.", + "908898c3ee": "Use Orca's browser", + "43781563c3": "Browse your web app without leaving Orca. Grab any element and send its exact source and styles to an agent with one click.", + "29aa2c2077": "Turn on notifications", + "71bd9a8c95": "Know the moment an agent finishes, needs attention, or gets blocked.", + "46db810da8": "Choose your default agent", + "b8e5bae17f": "Start new work faster with your preferred agent already selected.", + "fee5557b02": "Enable Orca CLI", + "7bcb4097fa": "Register the Orca shell command and install agent skills for browser, computer, and orchestration workflows.", + "ad342dd4c6": "Connect integrations", + "06fe30fdb0": "Start an agent from a task in one click and keep PR status in view.", + "eddc532e58": "Automate workspace setup", + "56049b74c2": "Run install and setup commands automatically so every new worktree is ready for agents.", + "2cf795433b": "Start work in multiple repos", + "42525ba8a4": "Bring your key repos into Orca so you can start agent work without hunting for folders." + } + } + } + }, "usage": { "tracking": { "b94ec70eda": "Tracking not set up", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index fa616915282..2e5fd1fd95f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12411,6 +12411,30 @@ }, "feature": { "wall": { + "setup": { + "checklist": { + "localized": { + "copy": { + "ec0a363633": "동시 작업", + "62bac8f43c": "동시에 2개의 서로 다른 워크트리에서 작업하세요. 같은 프로젝트 내에서도 각각 완벽히 격리되어 있어 2개의 기능을 동시에 작업하기에 적합합니다.", + "908898c3ee": "Orca 브라우저 사용", + "43781563c3": "Orca를 벗어나지 않고 웹 앱을 탐색하세요. 원하는 요소를 선택하고 클릭 한 번으로 정확한 소스와 스타일을 에이전트에 전달할 수 있습니다.", + "29aa2c2077": "알림 켜기", + "71bd9a8c95": "에이전트가 작업을 완료했거나, 확인이 필요하거나, 차단된 순간을 즉시 알 수 있습니다.", + "46db810da8": "기본 에이전트 선택", + "b8e5bae17f": "선호하는 에이전트를 미리 선택하여 새로운 작업을 더 빠르게 시작하세요.", + "fee5557b02": "Orca CLI 활성화", + "7bcb4097fa": "Orca 셸 명령을 등록하고 브라우저, 컴퓨터 및 오케스트레이션 워크플로를 위한 에이전트 스킬을 설치하세요.", + "ad342dd4c6": "서비스 연동", + "06fe30fdb0": "클릭 한 번으로 작업에서 에이전트를 시작하고 PR 상태를 한눈에 확인하세요.", + "eddc532e58": "워크스페이스 설정 자동화", + "56049b74c2": "설치 및 설정 명령을 자동으로 실행하여 모든 새 워크트리가 에이전트 작업을 바로 수행할 수 있도록 준비합니다.", + "2cf795433b": "여러 저장소에서 작업 시작", + "42525ba8a4": "주요 저장소를 Orca로 가져와 폴더를 일일이 찾을 필요 없이 에이전트 작업을 시작하세요." + } + } + } + }, "usage": { "tracking": { "b94ec70eda": "추적이 설정되지 않았습니다.",