From 1ba98015745c8db70ebd56ba10da9d6e8fe263c7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:33:04 -0700 Subject: [PATCH 01/13] fix(ci): stop hourly versions dropping below a tagged or already-shipped build (#20699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): stop hourly versions dropping below a tagged or already-shipped build Hourly/daily/adhoc based their X.Y.Z on GitHub releases, not git tags. When v1.4.202 was tagged and then its GitHub release vanished, the next hourlies shipped as 1.4.202-hourly — below both stable 1.4.202 and the 1.4.203-hourly builds already installed, so electron-updater stopped offering updates. Read main's v* tags and already-published channel tags instead. * docs(ci): record that 1.4.202's release was unpublished for a bug The leftover tag is what hourly must still honor; this was not a failed cut. --- .github/workflows/adhoc-mac-build.yml | 13 +++++--- .github/workflows/daily-mac-build.yml | 22 +++++++------ .github/workflows/hourly-mac-build.yml | 32 ++++++++++++------- config/scripts/dev-channel-base-version.mjs | 7 ++-- .../scripts/dev-channel-base-version.test.mjs | 21 ++++++++++++ config/scripts/hourly-build-version.test.mjs | 24 ++++++++++++++ .../workflow-ref-mirror-case-safety.test.mjs | 19 +++++++++++ 7 files changed, 110 insertions(+), 28 deletions(-) diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 53501add289..4f667bf2778 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -235,11 +235,14 @@ jobs: fi done echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - # Why the main repo's tags: package.json on a branch is as stale as the - # main it forked from, and stable patches never merge back into it. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag, which still owns that number. + # Releases-only let adhoc sit on a number already taken, so the updater + # would not install it. Empty on failure — the script then falls back + # to package.json. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \ node config/scripts/adhoc-build-version.mjs \ >"$RUNNER_TEMP/adhoc-identity.txt" diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index a758d8db3a1..41b87526fea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -90,7 +90,7 @@ jobs: uses: actions/checkout@v6 with: ref: main - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the daily repo through a minted App token passed by env. @@ -209,17 +209,21 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. That dragged hourlies backwards so + # electron-updater stopped offering them; dailies would do the same. A # separate token because GH_TOKEN above is the App's, scoped to the # daily repo. Empty on failure — the script then falls back to # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor so unpublishing a + # buggy main release cannot drag this series below a daily already out. + channel_tags="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ node config/scripts/daily-build-version.mjs \ >"$RUNNER_TEMP/daily-identity.txt" diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index b4bdd803a6a..1aed485a666 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -137,7 +137,7 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.head_sha }} - # Version helpers only read HEAD; published versions come from the release API. + # Version helpers only read HEAD; published versions come from git tags. fetch-depth: 1 # Why: this job only reads stablyai/orca and never pushes; every write # goes to the hourly repo through a minted App token passed by env. @@ -213,17 +213,25 @@ jobs: # number free", where a stranded draft still holds one. names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ --jq '.[].name // empty')" - # Why the main repo's tags decide the base version rather than - # package.json: main's version only moves on `release:` commits, and - # stable patches are cut from release branches that never merge back, so - # package.json can sit several patches behind what users are running. A - # separate token because GH_TOKEN above is the App's, scoped to the - # hourly repo. Empty on failure — the script then falls back to - # package.json, which is stale but never wrong enough to fail a build. - published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ - --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ - --json tagName --jq '.[].tagName' || true)" - echo "Highest published tag seen: $(head -1 <<<"$published")" + # Why git tags, not GitHub releases: unpublishing a buggy cut deletes the + # GitHub release and leaves the tag. On 2026-09-14 we deleted v1.4.202's + # release for a bug; hourlies had already climbed to 1.4.203, then + # `gh release list` fell back to v1.4.201 and the next hourlies shipped + # as 1.4.202-hourly — which electron-updater will not install over + # 1.4.203-hourly or over the still-tagged 1.4.202. A separate token + # because GH_TOKEN above is the App's, scoped to the hourly repo. Empty + # on failure — the script then falls back to package.json, which is + # stale but never wrong enough to fail a build. + main_tags="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh api \ + "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v" \ + --jq '.[].ref | sub("^refs/tags/"; "")' || true)" + # Already-shipped channel tags are a second floor: even if main's tag + # list is empty this run, a 1.4.203-hourly already out must not be + # followed by a 1.4.202-hourly. + channel_tags="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName \ + --jq '.[].tagName' || true)" + published="$main_tags"$'\n'"$channel_tags" + echo "Published version sources: $(grep -c . <<<"$main_tags" || true) main tags, $(grep -c . <<<"$channel_tags" || true) channel tags" ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" diff --git a/config/scripts/dev-channel-base-version.mjs b/config/scripts/dev-channel-base-version.mjs index 62a28c6a374..074af9ec3d9 100644 --- a/config/scripts/dev-channel-base-version.mjs +++ b/config/scripts/dev-channel-base-version.mjs @@ -25,8 +25,11 @@ function compareTriples(a, b) { * 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and * 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while * carrying code newer than 1.4.167, and sorted *below* the stable their user was - * already running. Published tags are the only honest answer to "what number is - * taken"; package.json is a floor, not a source of truth. + * already running. Git tags (not GitHub releases) are the honest answer to "what + * number is taken": unpublishing a buggy cut deletes the GitHub release and + * leaves the tag, which still owns that number. Channel tags (`1.4.203-hourly.*`) + * are a second floor so that unpublish cannot drag the series backwards. + * package.json is a floor, not a source of truth. */ export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) { const fromPackage = parseVersionTriple(packageVersion) diff --git a/config/scripts/dev-channel-base-version.test.mjs b/config/scripts/dev-channel-base-version.test.mjs index d2631eff0e9..00c13f6817a 100644 --- a/config/scripts/dev-channel-base-version.test.mjs +++ b/config/scripts/dev-channel-base-version.test.mjs @@ -39,6 +39,27 @@ describe('dev channel base version', () => { ) }) + // Why tags rather than GitHub releases: unpublishing a buggy cut deletes the + // GitHub release and leaves the tag. Releases-only then treated 1.4.202 as + // free, so hourlies sat on 1.4.202-hourly and sorted below that tagged stable. + it('climbs past a tagged stable that has no GitHub release', () => { + expect(resolveDevChannelBaseVersion('1.4.197', ['v1.4.201', 'v1.4.202'])).toBe('1.4.203') + }) + + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had already shipped as 1.4.203. Without the channel tags as a floor, the + // next hourlies would have been 1.4.202-hourly, which electron-updater will + // not install over 1.4.203-hourly. + it('does not drop below an already-published channel version', () => { + expect( + resolveDevChannelBaseVersion('1.4.197', [ + 'v1.4.201', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ]) + ).toBe('1.4.203') + }) + it('treats package.json as a floor when it leads the tags', () => { expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0') }) diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 08fc9d28b81..7438b16acbd 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createHourlyBuildVersion, formatHourlyReleaseName, + getHourlyBuildIdentity, nextHourlyBuildNumber } from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' @@ -120,3 +121,26 @@ describe('nextHourlyBuildNumber', () => { expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) }) }) + +describe('getHourlyBuildIdentity', () => { + // 2026-09-14: v1.4.202's GitHub release was deleted for a bug after hourlies + // had climbed to 1.4.203. Passing the leftover tag and the already-shipped + // hourly keeps the next build on 1.4.203 so electron-updater will still + // install it. + it('stays on the already-shipped hourly base after a buggy main release is unpublished', () => { + const identity = getHourlyBuildIdentity(new Date('2026-09-14T20:00:00Z'), { + publishedVersions: [ + 'v1.4.201', + 'v1.4.202', + 'v1.4.202-hourly.202609141912', + 'v1.4.203-hourly.202609140417' + ], + releaseNames: [ + '1.4.202 • 14 • Sep 14, 12:12PM • 875b86d', + '1.4.203 • 04 • Sep 13, 9:17PM • 2ce252f' + ] + }) + expect(identity.version).toBe('1.4.203-hourly.202609142000') + expect(identity.buildNumber).toBe(5) + }) +}) diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index 497a58653a0..8ebb2fced9d 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -30,6 +30,25 @@ describe('ref-mirroring vet steps', () => { ).toBe(true) }) + // Why matching-refs rather than `gh release list` on the main repo: a tagged + // stable still owns its number after its GitHub release is unpublished for a + // bug, and that unpublish must not drag the channel backwards. + it.each(['daily', 'hourly', 'adhoc'])( + '%s versions from git tags, not main GitHub releases', + (channel) => { + const step = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ + `build-${channel}-mac` + ].steps.find((candidate) => candidate.name === `Compute ${channel} version`) + expect(step.run).toContain('git/matching-refs/tags/v') + expect(step.run).not.toMatch( + /gh release list[\s\S]*--repo "\$GITHUB_REPOSITORY"[\s\S]*--json tagName/ + ) + if (channel !== 'adhoc') { + expect(step.run).toContain('channel_tags=') + } + } + ) + it('retains release-cut history for version reservation and retry ancestry', () => { const checkout = readWorkflow('.github/workflows/release-cut.yml').jobs.cut.steps.find( (step) => step.uses === 'actions/checkout@v6' From dede24df46a80ced43b7a732d820f768cbbd72e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:33:19 -0700 Subject: [PATCH 02/13] fix(store): preserve state identity for no-op updater branches (#20703) --- .../tab-drag-preview-activation.test.ts | 29 +++- .../tab-group/tab-drag-preview-activation.ts | 36 ++++- .../src/store/github/project-cache.ts | 4 +- .../store/github/pull-request-execution.ts | 4 +- .../github/work-item-mutation-actions.ts | 2 +- .../slices/browser/browser-host-actions.ts | 4 +- .../slices/browser/browser-host-state.ts | 2 +- .../browser/browser-hydration-actions.ts | 2 +- .../browser/browser-profile-import-actions.ts | 8 +- .../store/slices/commit-message-generation.ts | 4 +- .../store/slices/diff-comment-persistence.ts | 18 +-- .../slices/empty-update-notifications.test.ts | 81 ++++++++++ ...hub-pr-branch-linked-pr-divergence.test.ts | 4 + .../github-work-item-cache-identity.test.ts | 9 ++ .../slices/hosted-review-cache-race.test.ts | 4 + .../src/store/slices/hosted-review.ts | 2 +- .../store/slices/jira-issue-patch-action.ts | 2 +- .../linear/linear-invalidation-actions.ts | 2 +- .../store/slices/pull-request-generation.ts | 4 +- .../src/store/slices/sparse-presets.ts | 2 +- .../store/slices/tabs/tabs-create-actions.ts | 2 +- .../store/slices/tabs/tabs-drop-actions.ts | 8 +- .../store/slices/tabs/tabs-focus-actions.ts | 2 +- .../store/slices/tabs/tabs-label-actions.ts | 35 +++-- .../store/slices/tabs/tabs-move-actions.ts | 6 +- .../create/pending-worktree-creation.ts | 8 +- .../metadata/hosted-review-link-mutation.ts | 2 +- .../metadata/update-worktree-meta.ts | 4 +- .../metadata/update-worktrees-meta.ts | 2 +- .../session/migrate-worktree-identity.ts | 5 +- .../worktrees/session/set-active-worktree.ts | 6 +- .../worktree-no-op-notifications.test.ts | 64 ++++++++ .../session/worktree-slice-lookups.ts | 6 +- .../session/worktree-unread-activity.ts | 4 +- .../session/worktree-visit-recency.ts | 8 +- .../teardown/worktree-delete-state.ts | 6 +- .../terminal-disowned-pty-sources.ts | 2 +- .../terminals/terminal-ephemeral-state.ts | 16 +- .../store/terminals/terminal-layout-state.ts | 2 +- .../terminal-no-op-subscriber.test.ts | 146 ++++++++++++++++++ .../store/terminals/terminal-restart-state.ts | 10 +- .../terminals/terminal-startup-queues.ts | 2 +- .../terminals/terminal-unverified-pty-loss.ts | 2 +- 43 files changed, 472 insertions(+), 99 deletions(-) create mode 100644 src/renderer/src/store/slices/empty-update-notifications.test.ts create mode 100644 src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts index 019b3e297ee..1269ade41a4 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -1,10 +1,11 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { applyDragPreviewTab, captureTabDragActivationSnapshot, - restoreTabDragActivationSnapshot + restoreTabDragActivationSnapshot, + restoreSourceGroupActiveTabAfterCrossGroupDrop } from './tab-drag-preview-activation' const WT = 'wt-preview-restore' @@ -59,6 +60,30 @@ describe('restoreTabDragActivationSnapshot', () => { }) }) + it('does not publish repeated preview and restore actions', () => { + const snapshot = captureTabDragActivationSnapshot(WT) + const subscriber = vi.fn() + const unsubscribe = useAppStore.subscribe(subscriber) + try { + applyDragPreviewTab({ + worktreeId: WT, + groupId: 'group-1', + tabId: 'tab-1', + activeGroupId: 'group-1' + }) + restoreTabDragActivationSnapshot(WT, snapshot) + restoreSourceGroupActiveTabAfterCrossGroupDrop({ + worktreeId: WT, + snapshot, + sourceGroupId: 'group-1', + movedTabId: 'tab-2' + }) + expect(subscriber).not.toHaveBeenCalled() + } finally { + unsubscribe() + } + }) + it('restores active-surface fields after a drag preview is cancelled', () => { const snapshot = captureTabDragActivationSnapshot(WT) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts index a0060271c99..31726672db2 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -30,6 +30,14 @@ function previewActiveSurfacePatch( }) if (unifiedTab.contentType === 'terminal') { + if ( + state.activeTabType === 'terminal' && + state.activeTabTypeByWorktree[worktreeId] === 'terminal' && + state.activeTabId === unifiedTab.entityId && + state.activeTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeTabId: unifiedTab.entityId, activeTabType: 'terminal', @@ -41,6 +49,14 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'browser') { + if ( + state.activeTabType === 'browser' && + state.activeTabTypeByWorktree[worktreeId] === 'browser' && + state.activeBrowserTabId === unifiedTab.entityId && + state.activeBrowserTabIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeBrowserTabId: unifiedTab.entityId, activeTabType: 'browser', @@ -52,11 +68,25 @@ function previewActiveSurfacePatch( } } if (unifiedTab.contentType === 'simulator') { + if ( + state.activeTabType === 'simulator' && + state.activeTabTypeByWorktree[worktreeId] === 'simulator' + ) { + return {} + } return { activeTabType: 'simulator', activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator') } } + if ( + state.activeTabType === 'editor' && + state.activeTabTypeByWorktree[worktreeId] === 'editor' && + state.activeFileId === unifiedTab.entityId && + state.activeFileIdByWorktree[worktreeId] === unifiedTab.entityId + ) { + return {} + } return { activeFileId: unifiedTab.entityId, activeTabType: 'editor', @@ -95,7 +125,7 @@ export function applyDragPreviewTab({ const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId) if (groupUnchanged && focusUnchanged) { - return Object.keys(surfacePatch).length > 0 ? surfacePatch : {} + return Object.keys(surfacePatch).length > 0 ? surfacePatch : state } const next: Partial = { ...surfacePatch } @@ -162,7 +192,7 @@ export function restoreTabDragActivationSnapshot( } if (Object.keys(next).length === 0) { - return {} + return state } return next @@ -191,7 +221,7 @@ export function restoreSourceGroupActiveTabAfterCrossGroupDrop({ const groups = state.groupsByWorktree[worktreeId] ?? [] const sourceGroup = groups.find((group) => group.id === sourceGroupId) if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) { - return {} + return state } return { groupsByWorktree: { diff --git a/src/renderer/src/store/github/project-cache.ts b/src/renderer/src/store/github/project-cache.ts index df8ac361025..eb296633201 100644 --- a/src/renderer/src/store/github/project-cache.ts +++ b/src/renderer/src/store/github/project-cache.ts @@ -76,11 +76,11 @@ export function applyRowPatch( set((s) => { const entry = s.projectViewCache[cacheKey] if (!entry?.data) { - return {} + return s } const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId) if (rowIndex === -1) { - return {} + return s } const rows = [...entry.data.rows] rows[rowIndex] = nextRow diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 72069802064..553ef5c1e13 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -153,7 +153,7 @@ export function startPullRequestLookup(args: { // Why: unlinking a PR mid exact-linked-PR-lookup must stop the older result from restoring the manual link UI. if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { skippedStaleLinkedPRLookup = true - return {} + return s } const updates = setGitHubPRResultCaches(s, { prCacheKey: cacheKey, @@ -174,7 +174,7 @@ export function startPullRequestLookup(args: { requestStartedEntry: requestStartedHostedReviewEntry }) didUpdatePRCache = updates.prCache !== undefined - return updates + return updates.prCache || updates.hostedReviewCache ? updates : s }) if (skippedStaleLinkedPRLookup) { return null diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index e1d02a09d3b..97a6d3b66f3 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -45,7 +45,7 @@ export const createWorkItemMutationActions = ( nextCache[key] = { ...entry, data: updatedItems } changed = true } - return changed ? { workItemsCache: nextCache } : {} + return changed ? { workItemsCache: nextCache } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-actions.ts b/src/renderer/src/store/slices/browser/browser-host-actions.ts index 2977717d38c..193dfdafe60 100644 --- a/src/renderer/src/store/slices/browser/browser-host-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-host-actions.ts @@ -24,7 +24,7 @@ export function createBrowserHostActions( closes, Date.now() ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, @@ -34,7 +34,7 @@ export function createBrowserHostActions( s.clientHostedBrowserCloseIntentsByEnvironment, { environmentId, browserPageIds, now: Date.now() } ) - return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : {} + return next ? { clientHostedBrowserCloseIntentsByEnvironment: next } : s }) }, diff --git a/src/renderer/src/store/slices/browser/browser-host-state.ts b/src/renderer/src/store/slices/browser/browser-host-state.ts index 1b4b3a70e9a..7ee75ac54a1 100644 --- a/src/renderer/src/store/slices/browser/browser-host-state.ts +++ b/src/renderer/src/store/slices/browser/browser-host-state.ts @@ -153,7 +153,7 @@ export function browserImportStateForHostUpdate( hostId: ExecutionHostId, browserSessionImportState: BrowserSlice['browserSessionImportState'] ): Partial { - return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : {} + return getBrowserSettingsHostId(state) === hostId ? { browserSessionImportState } : state } export function getFallbackTabTypeForWorktree( diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index a2ed3f5bbb1..13beee05872 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -257,7 +257,7 @@ export function createBrowserHydrationActions( } } } - return {} + return s }) } } diff --git a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts index ec03690bf8e..9b79ec9adbc 100644 --- a/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-profile-import-actions.ts @@ -133,13 +133,13 @@ export function createBrowserProfileImportActions( set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: browsers, detectedBrowsersLoaded: true, detectedBrowsersHost } - : {} + : s ) } catch { set((s) => getBrowserSettingsHostId(s) === hostId ? { detectedBrowsers: [], detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } return @@ -161,11 +161,11 @@ export function createBrowserProfileImportActions( detectedBrowsersLoaded: true, detectedBrowsersHost: null } - : {} + : s ) } catch { /* best-effort — empty list is acceptable fallback */ - set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : {})) + set((s) => (getBrowserSettingsHostId(s) === hostId ? { detectedBrowsersLoaded: true } : s)) } } } diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts index e361958932c..4e7ef3e722d 100644 --- a/src/renderer/src/store/slices/commit-message-generation.ts +++ b/src/renderer/src/store/slices/commit-message-generation.ts @@ -160,7 +160,7 @@ export const createCommitMessageGenerationSlice: StateCreator< set((state) => { const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { commitMessageGenerationRecords: { @@ -184,6 +184,6 @@ export const createCommitMessageGenerationSlice: StateCreator< changed = true } } - return changed ? { commitMessageGenerationRecords: nextRecords } : {} + return changed ? { commitMessageGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts index 92e1eb9ea9f..4d28d7b7f1d 100644 --- a/src/renderer/src/store/slices/diff-comment-persistence.ts +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -239,13 +239,13 @@ export function mutateDiffComments( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) if (!target) { - return {} + return s } folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed return { @@ -256,16 +256,16 @@ export function mutateDiffComments( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) if (!target) { - return {} + return s } previous = target.diffComments const computed = mutate(previous ?? []) if (computed === null) { - return {} + return s } next = computed const nextList: Worktree[] = repoList.map((w) => @@ -293,7 +293,7 @@ function rollback( if (scope?.type === 'folder') { const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) if (!target || target.diffComments !== expectedCurrent) { - return {} + return s } return { folderWorkspaces: s.folderWorkspaces.map((workspace) => @@ -303,16 +303,16 @@ function rollback( } const repoList = s.worktreesByRepo[repoId] if (!repoList) { - return {} + return s } const target = repoList.find((w) => w.id === worktreeId) // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. if (!target) { - return {} + return s } // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. if (target.diffComments !== expectedCurrent) { - return {} + return s } const nextList: Worktree[] = repoList.map((w) => w.id === worktreeId ? { ...w, diffComments: previous } : w diff --git a/src/renderer/src/store/slices/empty-update-notifications.test.ts b/src/renderer/src/store/slices/empty-update-notifications.test.ts new file mode 100644 index 00000000000..692fa50b7d4 --- /dev/null +++ b/src/renderer/src/store/slices/empty-update-notifications.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import { createTabsSliceMockApi } from './tabs-slice-test-harness' +import { browserImportStateForHostUpdate } from './browser/browser-host-state' +import { mutateDiffComments } from './diff-comment-persistence' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createTabsSliceMockApi() + +describe('empty store updates', () => { + it('does not notify for missing tab actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel('missing', 'label') + before.setTabCustomLabel('missing', 'label') + before.setUnifiedTabColor('missing', null) + before.setTabViewMode('missing', 'chat') + before.toggleTabViewMode('missing') + before.pinTab('missing') + before.unpinTab('missing') + before.reorderUnifiedTabs('missing', []) + before.moveUnifiedTabToGroup('missing', 'missing') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for unchanged labels but publishes changed labels', () => { + const store = createTestStore() + const tab = store + .getState() + .createUnifiedTab('folder-workspace', 'terminal', { label: 'label' }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.setTabLabel(tab.id, 'label') + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.setTabLabel(tab.id, 'new label') + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState().getTab(tab.id)?.label).toBe('new label') + }) + + it('does not notify for rejected generation updates or empty pruning', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updateCommitMessageGenerationRecord('missing', () => null) + before.updatePullRequestGenerationRecord('missing', () => null) + before.pruneCommitMessageGenerationRecords(new Set()) + before.prunePullRequestGenerationRecords(new Set()) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('does not notify for absent Jira issues, browser pages or diff comments', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.patchJiraIssue('MISSING-1', {}) + before.patchLinearIssue('missing', {}) + before.switchBrowserTabProfile('missing', null, 'persist:missing') + before.recordClientHostedBrowserCloseIntents([]) + before.clearClientHostedBrowserCloseIntents('missing', []) + mutateDiffComments(store.setState, 'missing', () => null) + store.setState((state) => browserImportStateForHostUpdate(state, 'runtime:other', null)) + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts index 418b6648fa0..8b42bf3e56f 100644 --- a/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-linked-pr-divergence.test.ts @@ -86,6 +86,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { hostedReviewCache: {}, prCache: {} } as unknown as Partial) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) resolveRefresh({ kind: 'found', pr: makePR({ number: 12, title: 'Stale exact linked PR' }), @@ -93,6 +95,8 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) await expect(request).resolves.toBeNull() + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts index ef22da072d1..ba741de5cfb 100644 --- a/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts +++ b/src/renderer/src/store/slices/github-work-item-cache-identity.test.ts @@ -15,6 +15,15 @@ describe('createGitHubSlice.patchWorkItem', () => { resetRemoteRuntimeMocks() }) + it('does not notify when a patch has no matching cached work item', () => { + const store = createTestStore() + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + store.getState().patchWorkItem('pr:missing', { title: 'Missing' }, 'repo-1') + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() + }) + it('can scope patches to one repo when different repos have the same work-item id', () => { const store = createTestStore() const repoOneItem = { diff --git a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts index 59e253e1823..16d765fe0d5 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-race.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-race.test.ts @@ -112,10 +112,14 @@ describe('hosted review cache race protection', () => { } } }) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) vi.setSystemTime(300) resolveFetch(olderReview) await expect(request).resolves.toEqual(olderReview) + unsubscribe() + expect(subscriber).not.toHaveBeenCalled() expect(store.getState().hostedReviewCache[cacheKey]).toEqual({ data: newerReview, fetchedAt: 200, diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 53cdb00db55..cad350d986b 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -234,7 +234,7 @@ export const createHostedReviewSlice: StateCreator { const nextRecord = updater(state.pullRequestGenerationRecords[key] ?? null) if (!nextRecord) { - return {} + return state } return { pullRequestGenerationRecords: { @@ -312,6 +312,6 @@ export const createPullRequestGenerationSlice: StateCreator< changed = true } } - return changed ? { pullRequestGenerationRecords: nextRecords } : {} + return changed ? { pullRequestGenerationRecords: nextRecords } : state }) }) diff --git a/src/renderer/src/store/slices/sparse-presets.ts b/src/renderer/src/store/slices/sparse-presets.ts index b9e763bf72e..6a4a2d68245 100644 --- a/src/renderer/src/store/slices/sparse-presets.ts +++ b/src/renderer/src/store/slices/sparse-presets.ts @@ -145,7 +145,7 @@ export const createSparsePresetsSlice: StateCreator { const existing = s.sparsePresetsByRepo[args.repoId] if (existing === undefined) { - return {} + return s } const without = existing.filter((preset) => preset.id !== saved.id) return { diff --git a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts index b4f1d611b2f..f8736ed250b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-create-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-create-actions.ts @@ -120,7 +120,7 @@ export function createTabsCreateActions( target.sourceGroupId ) if (!sourceGroup) { - return {} + return state } const existingTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const currentGroups = state.groupsByWorktree[worktreeId] ?? [] diff --git a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts index 2cadf76365e..612bee0b538 100644 --- a/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-drop-actions.ts @@ -25,19 +25,19 @@ export function createTabsDropActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, target.groupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } const isSplitDrop = Boolean(target.splitDirection) if (!isSplitDrop && tab.groupId === target.groupId) { - return {} + return state } const layout = state.layoutByWorktree[worktreeId] if ( @@ -51,7 +51,7 @@ export function createTabsDropActions( }) ) { // Why: dropping a group's last tab on its own/sibling matching edge only makes a transient column that immediately collapses. - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts index 39a0dd38858..6e4134c6a1b 100644 --- a/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-focus-actions.ts @@ -53,7 +53,7 @@ export function createTabsFocusActions( found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) } if (!found) { - return {} + return state } const { tab, worktreeId } = found // Why: activating a terminal tab dismisses its tab-level bell — the user has moved their eyes here. diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 42b8e3d7163..3ee06aa105c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -50,7 +50,7 @@ export function createTabsLabelActions( } } } - return {} + return state }) if (reordered && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -58,17 +58,24 @@ export function createTabsLabelActions( }, setTabLabel: (tabId, label) => { - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? state) }, setTabViewMode: (tabId, mode) => { - set((state) => ({ - ...patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }), - // Why the row too: viewMode is declared on both types and host-sync - // already writes it to the row. Only these local toggles skipped it, so - // readers had to OR the two indices to find out who owns the surface. - ...patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) - })) + set((state) => { + const tabPatch = patchTab(state.unifiedTabsByWorktree, tabId, { viewMode: mode }) + const rowPatch = patchTerminalTabRow(state.tabsByWorktree, tabId, { viewMode: mode }) + if (!tabPatch && !rowPatch.tabsByWorktree) { + return state + } + return { + ...tabPatch, + // Why the row too: viewMode is declared on both types and host-sync + // already writes it to the row. Only these local toggles skipped it, so + // readers had to OR the two indices to find out who owns the surface. + ...rowPatch + } + }) mirrorTabViewModeToHost(get(), tabId, mode) }, @@ -81,7 +88,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } // Why: viewMode defaults to 'terminal' for legacy/missing, so the first toggle flips to 'chat'. const fromMode: 'terminal' | 'chat' = found.tab.viewMode === 'chat' ? 'chat' : 'terminal' @@ -111,7 +118,7 @@ export function createTabsLabelActions( setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? state) if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -119,7 +126,7 @@ export function createTabsLabelActions( setUnifiedTabColor: (tabId, color) => { const exists = get().getTab(tabId) !== null - set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? {}) + set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) if (exists) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -130,7 +137,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => @@ -168,7 +175,7 @@ export function createTabsLabelActions( set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { - return {} + return state } const { tab, worktreeId } = found const tabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) => diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 4c651f613ac..8e8a51f5c0c 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -22,16 +22,16 @@ export function createTabsMoveActions( const foundTab = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) const foundTarget = findGroupAndWorktree(state.groupsByWorktree, targetGroupId) if (!foundTab || !foundTarget || foundTab.worktreeId !== foundTarget.worktreeId) { - return {} + return state } const { tab, worktreeId } = foundTab if (tab.groupId === targetGroupId) { - return {} + return state } const sourceGroup = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) const targetGroup = foundTarget.group if (!sourceGroup) { - return {} + return state } moved = true diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 28530219cce..435eadec4ec 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -23,14 +23,14 @@ export function createUpdatePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( (key) => patch[key] !== entry[key] ) if (!hasChange) { - return {} + return s } return { pendingWorktreeCreations: { @@ -51,7 +51,7 @@ export function createRemovePendingWorktreeCreation( set((s) => { const entry = s.pendingWorktreeCreations[creationId] if (!entry) { - return {} + return s } removedEntry = entry const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations @@ -90,7 +90,7 @@ export function createSetActivePendingWorktreeCreation( return (creationId) => { set((s) => { if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { - return {} + return s } return { activePendingCreationId: creationId } }) diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts index fdf17d38f8f..35464523ba5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts @@ -261,7 +261,7 @@ export function applyHostedReviewLinkClear( nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo ) { - return {} + return s } return { ...(nextWorktrees !== s.worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 5e5c38a71e2..414a2ef206a 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -149,7 +149,7 @@ export function createUpdateWorktreeMeta( shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId, executionHostId)) ) { - return {} + return s } didApply = true const nextWorktrees = applyWorktreeUpdates( @@ -204,7 +204,7 @@ export function createUpdateWorktreeMeta( !cacheKey && !prCacheKey ) { - return {} + return s } const nextHostedReviewCache = diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts index 78254535106..8ae7be3ea8b 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts @@ -73,7 +73,7 @@ export function createUpdateWorktreesMeta( } return nextWorktrees === s.worktreesByRepo && nextDetectedWorktrees === s.detectedWorktreesByRepo - ? {} + ? s : { ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } diff --git a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts index 75d6059f309..d14392e72d9 100644 --- a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts +++ b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts @@ -14,7 +14,10 @@ export function createMigrateWorktreeIdentity( } // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) - set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) + set((s) => { + const patch = buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId) + return Object.keys(patch).length > 0 ? patch : s + }) migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 31c7e8bbb37..84c85c45a3c 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -217,15 +217,15 @@ export function createSetActiveWorktree( pendingActivationTerminalPrepCancels.delete(worktreeId) set((s) => { if (s.activeWorktreeId !== worktreeId) { - return {} + return s } const tabs = s.tabsByWorktree[worktreeId] ?? [] if (tabs.length === 0) { - return {} + return s } const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) if (!allDead && !shouldTagTerminalTabs) { - return {} + return s } return { tabsByWorktree: { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts new file mode 100644 index 00000000000..7fdaea7999f --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/worktree-no-op-notifications.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../../worktrees-slice-test-harness' +import { makeWorktree } from '../../worktrees-slice-test-fixtures' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), info: vi.fn(), success: vi.fn(), error: vi.fn(), dismiss: vi.fn() } +})) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice: vi.fn() +})) + +describe('worktree no-op notifications', () => { + it('keeps missing creation, recovery, activity, deletion and visit updates silent', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.updatePendingWorktreeCreation('missing', { phase: 'fetching' }) + before.removePendingWorktreeCreation('missing') + before.setActivePendingWorktreeCreation('missing') + before.remountTerminalTabForRecovery('missing') + before.settleTerminalTabRecovery('missing', 1, 'success') + before.markWorktreeUnread('missing') + before.bumpWorktreeActivity('missing') + before.clearWorktreeDeleteState('missing') + before.seedActiveWorktreeLastVisitedIfMissing() + before.pruneLastVisitedTimestamps() + before.migrateWorktreeIdentity('missing-old', 'missing-new') + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it.each(['local', 'ssh:test'] as const)( + 'keeps repeated %s deletion and visit updates silent', + (hostId) => { + const store = createTestStore() + const worktree = makeWorktree({ id: 'repo1::/path/wt', repoId: 'repo1', hostId }) + store.setState({ worktreesByRepo: { repo1: [worktree] } }) + const target = { id: worktree.id, hostId } + store.getState().markWorktreesQueuedForDeletion([target]) + store.getState().markWorktreeVisited(worktree.id, 100, hostId) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + before.markWorktreesQueuedForDeletion([target]) + before.markWorktreeVisited(worktree.id, 100, hostId) + before.markWorktreeVisited(worktree.id, 99, hostId) + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + + before.markWorktreesDeleting([target]) + expect(listener).toHaveBeenCalledTimes(1) + const deleting = store.getState() + deleting.markWorktreesDeleting([target]) + expect(store.getState()).toBe(deleting) + expect(listener).toHaveBeenCalledTimes(1) + deleting.clearWorktreeDeleteState(worktree.id, hostId) + expect(listener).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 78e0e397bca..66e6ddf5b51 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -55,7 +55,7 @@ export function createRemountTerminalTabForRecovery( const { admitted: _admitted, ...decline } = admission result = { remounted: false, ...decline } } - return {} + return s } const { worktreeId, index, tab } = location const nextTabs = s.tabsByWorktree[worktreeId].slice() @@ -110,12 +110,12 @@ export function createSettleTerminalTabRecovery( set((s) => { const location = locateTerminalTab(s.tabsByWorktree, tabId) if (!location) { - return {} + return s } const { worktreeId, index, tab } = location const recovery = settledTerminalRecoveryLedger(tab, generation, outcome) if (!recovery) { - return {} + return s } const nextTabs = s.tabsByWorktree[worktreeId].slice() nextTabs[index] = { ...tab, recovery } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index 72d95b6a0a4..bea375b8292 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -59,7 +59,7 @@ export function createMarkWorktreeUnread( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree || worktree.isUnread) { - return {} + return s } shouldPersist = true const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { @@ -266,7 +266,7 @@ export function createBumpWorktreeActivity( set((s) => { const worktree = findKnownWorktreeById(s, worktreeId) if (!worktree) { - return {} + return s } shouldPersist = true // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 5cfc5eef3d2..7a9d94c89fd 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -29,7 +29,7 @@ export function createMarkWorktreeVisited( hostId: ownerHostId }) ?? 0 if (!(now > prev)) { - return {} + return s } return { lastVisitedAtByWorktreeId: { @@ -124,7 +124,7 @@ export function createPruneLastVisitedTimestamps( patch.activeWorkspaceExecutionHostId = null } } - return Object.keys(patch).length > 0 ? patch : {} + return Object.keys(patch).length > 0 ? patch : s }) } } @@ -137,12 +137,12 @@ export function createSeedActiveWorktreeLastVisitedIfMissing( set((s) => { const id = s.activeWorktreeId if (!id) { - return {} + return s } const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId const key = getWorktreeVisitKey(id, hostId) if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) { - return {} + return s } return { lastVisitedAtByWorktreeId: { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts index 30975b958e6..7e89bb2e34b 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts @@ -78,7 +78,7 @@ export function createMarkWorktreesDeleting( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -113,7 +113,7 @@ export function createMarkWorktreesQueuedForDeletion( } changed = true } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + return changed ? { deleteStateByWorktreeId: nextDeleteState } : s }) } } @@ -128,7 +128,7 @@ export function createClearWorktreeDeleteState( : worktreeId set((s) => { if (!s.deleteStateByWorktreeId[key]) { - return {} + return s } const next = { ...s.deleteStateByWorktreeId } delete next[key] diff --git a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts index d67a382a421..fc3abb5edd6 100644 --- a/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts +++ b/src/renderer/src/store/terminals/terminal-disowned-pty-sources.ts @@ -16,7 +16,7 @@ export function createTerminalDisownedPtySourceActions( markPtySourceDisowned: (ptyId) => { set((state) => state.disownedPtyIds[ptyId] - ? {} + ? state : { disownedPtyIds: { ...state.disownedPtyIds, [ptyId]: true } } ) } diff --git a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts index 89a67bd72ae..80de6b6899a 100644 --- a/src/renderer/src/store/terminals/terminal-ephemeral-state.ts +++ b/src/renderer/src/store/terminals/terminal-ephemeral-state.ts @@ -30,7 +30,7 @@ export function createTerminalEphemeralActions( markDefaultTerminalTabsApplied: (worktreeId) => set((s) => { if (s.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { - return {} + return s } return { defaultTerminalTabsAppliedByWorktreeId: { @@ -70,7 +70,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchPromptByTabId[tabId] if (!current || current.failed) { - return {} + return s } return { nativeChatLaunchPromptByTabId: { @@ -83,7 +83,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchPrompt: (tabId) => { set((s) => { if (!s.nativeChatLaunchPromptByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchPromptByTabId } delete next[tabId] @@ -102,7 +102,7 @@ export function createTerminalEphemeralActions( set((s) => { const current = s.nativeChatLaunchDraftByTabId[tabId] if (!current || current.adopted) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -121,7 +121,7 @@ export function createTerminalEphemeralActions( current.createdAt !== resolution.createdAt || current.text !== resolution.text ) { - return {} + return s } return { nativeChatLaunchDraftByTabId: { @@ -134,7 +134,7 @@ export function createTerminalEphemeralActions( clearNativeChatLaunchDraft: (tabId) => { set((s) => { if (!s.nativeChatLaunchDraftByTabId[tabId]) { - return {} + return s } const next = { ...s.nativeChatLaunchDraftByTabId } delete next[tabId] @@ -168,7 +168,7 @@ export function createTerminalEphemeralActions( next ??= { ...s.lastTerminalInputAtByPaneKey } next[key] = at } - return next ? { lastTerminalInputAtByPaneKey: next } : {} + return next ? { lastTerminalInputAtByPaneKey: next } : s }) } }) @@ -227,7 +227,7 @@ export function createTerminalEphemeralActions( removeDeferredSshSessionId: (tabId) => set((s) => { if (!s.deferredSshSessionIdsByTabId[tabId]) { - return {} + return s } const next = { ...s.deferredSshSessionIdsByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-layout-state.ts b/src/renderer/src/store/terminals/terminal-layout-state.ts index 9b5cf8323c2..42ad3dbc659 100644 --- a/src/renderer/src/store/terminals/terminal-layout-state.ts +++ b/src/renderer/src/store/terminals/terminal-layout-state.ts @@ -30,7 +30,7 @@ export function createTerminalLayoutActions( set((s) => { const layout = s.terminalLayoutsByTabId[tabId] if (!layout || layout.ptyIdsByLeafId?.[leafId] === ptyId) { - return {} + return s } return { terminalLayoutsByTabId: { diff --git a/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts new file mode 100644 index 00000000000..4d68e867770 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-no-op-subscriber.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + flushTerminalInputActivity, + resetTerminalInputActivityCoalescingForTests +} from '@/lib/terminal-input-activity-coalescing' +import { createTestStore, makeLayout } from '../slices/store-test-helpers' + +afterEach(resetTerminalInputActivityCoalescingForTests) + +describe('terminal no-op subscriber budget', () => { + it('does not publish missing-entry cleanup and restart actions', () => { + const store = createTestStore() + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + for (let i = 0; i < 25; i += 1) { + const s = store.getState() + s.replaceTerminalLayoutPanePtyId('missing', 'leaf', 'pty') + expect(s.consumeSuppressedPtyExit('missing')).toBe(false) + expect(s.consumePendingCodexPaneRestart('missing')).toBe(false) + s.clearCodexRestartNotice('missing') + s.dismissCodexRestartNotices(['missing']) + s.reopenCodexRestartPrompt('missing') + s.markNativeChatLaunchPromptFailed('missing') + s.clearNativeChatLaunchPrompt('missing') + s.markNativeChatLaunchDraftAdopted('missing') + s.resolveNativeChatLaunchDraft('missing', { text: 'draft', createdAt: 1 }) + s.clearNativeChatLaunchDraft('missing') + s.removeDeferredSshSessionId('missing') + } + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + }) + + it('publishes real mutations once and keeps repeated actions silent', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'draft', createdAt: 1 } as const + store.getState().seedNativeChatLaunchPrompt(draft) + store.getState().seedNativeChatLaunchDraft(draft) + store.getState().setTabLayout('tab', makeLayout()) + const listener = vi.fn() + store.subscribe(listener) + + const actions = [ + () => store.getState().markDefaultTerminalTabsApplied('folder-workspace'), + () => store.getState().markUnverifiedPtyLoss('tab'), + () => store.getState().markPtySourceDisowned('pty'), + () => store.getState().markNativeChatLaunchPromptFailed('tab'), + () => store.getState().markNativeChatLaunchDraftAdopted('tab'), + () => store.getState().resolveNativeChatLaunchDraft('tab', draft), + () => store.getState().replaceTerminalLayoutPanePtyId('tab', 'leaf', 'pty'), + () => store.getState().clearNativeChatLaunchPrompt('tab'), + () => store.getState().clearNativeChatLaunchDraft('tab') + ] + for (const action of actions) { + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + }) + + it('retains draft generations when stale resolutions arrive without notifying', () => { + const store = createTestStore() + const draft = { tabId: 'tab', agent: 'codex', text: 'new draft', createdAt: 2 } as const + store.getState().seedNativeChatLaunchDraft(draft) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, createdAt: 1 }) + store.getState().resolveNativeChatLaunchDraft('tab', { ...draft, text: 'old draft' }) + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().nativeChatLaunchDraftByTabId.tab).toBe(draft) + }) + + it('consumes real restart entries and leaves repeated consumes silent', () => { + const store = createTestStore() + store.getState().suppressPtyExit('pty') + store.getState().queueCodexPaneRestarts(['pty']) + const listener = vi.fn() + store.subscribe(listener) + + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(true) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(true) + expect(listener).toHaveBeenCalledTimes(2) + const before = store.getState() + expect(store.getState().consumeSuppressedPtyExit('pty')).toBe(false) + expect(store.getState().consumePendingCodexPaneRestart('pty')).toBe(false) + expect(listener).toHaveBeenCalledTimes(2) + expect(store.getState()).toBe(before) + }) + + it('drops a trailing input flush after pane teardown without publishing', () => { + const store = createTestStore() + store.getState().recordTerminalInput('tab:leaf', 1000) + store.getState().recordTerminalInput('tab:leaf', 1001) + store.setState({ lastTerminalInputAtByPaneKey: {} }) + const before = store.getState() + const listener = vi.fn() + store.subscribe(listener) + + flushTerminalInputActivity() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState()).toBe(before) + expect(store.getState().lastTerminalInputAtByPaneKey['tab:leaf']).toBeUndefined() + }) + + it('dismisses, reopens and clears restart notices without replaying no-op notifications', () => { + const store = createTestStore() + store + .getState() + .markCodexRestartNotices([ + { ptyId: 'pty', previousAccountLabel: 'old', nextAccountLabel: 'new' } + ]) + const listener = vi.fn() + store.subscribe(listener) + const actions = [ + () => store.getState().dismissCodexRestartNotices(['pty']), + () => store.getState().reopenCodexRestartPrompt('pty'), + () => store.getState().clearCodexRestartNotice('pty') + ] + for (const [index, action] of actions.entries()) { + if (index === 1) { + store.getState().queueCodexPaneRestarts(['pty']) + } + listener.mockClear() + action() + expect(listener).toHaveBeenCalledTimes(1) + const before = store.getState() + action() + expect(listener).toHaveBeenCalledTimes(1) + expect(store.getState()).toBe(before) + } + expect(store.getState().codexRestartNoticeByPtyId.pty).toBeUndefined() + expect(store.getState().pendingCodexPaneRestartIds.pty).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-restart-state.ts b/src/renderer/src/store/terminals/terminal-restart-state.ts index f1075bdb698..2b712b6ccdb 100644 --- a/src/renderer/src/store/terminals/terminal-restart-state.ts +++ b/src/renderer/src/store/terminals/terminal-restart-state.ts @@ -21,7 +21,7 @@ export function createTerminalRestartActions( let wasSuppressed = false set((s) => { if (!s.suppressedPtyExitIds[ptyId]) { - return {} + return s } wasSuppressed = true const next = { ...s.suppressedPtyExitIds } @@ -68,7 +68,7 @@ export function createTerminalRestartActions( let wasQueued = false set((s) => { if (!s.pendingCodexPaneRestartIds[ptyId]) { - return {} + return s } wasQueued = true const next = { ...s.pendingCodexPaneRestartIds } @@ -144,7 +144,7 @@ export function createTerminalRestartActions( clearCodexRestartNotice: (ptyId) => { set((s) => { if (!s.codexRestartNoticeByPtyId[ptyId]) { - return {} + return s } const next = { ...s.codexRestartNoticeByPtyId } const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } @@ -175,7 +175,7 @@ export function createTerminalRestartActions( changed = true } if (!changed) { - return {} + return s } return { codexRestartNoticeByPtyId: next, @@ -187,7 +187,7 @@ export function createTerminalRestartActions( set((s) => { const notice = s.codexRestartNoticeByPtyId[ptyId] if (!notice?.restartRequested) { - return {} + return s } const { restartRequested: _restartRequested, ...kept } = notice const nextPendingCodexPaneRestartIds = { ...s.pendingCodexPaneRestartIds } diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index 0f684e3f8b7..af56050ef23 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -62,7 +62,7 @@ export function createTerminalStartupQueueActions( } set((s) => { if (s.pendingStartupByTabId[tabId] !== pending) { - return {} + return s } const next = { ...s.pendingStartupByTabId } delete next[tabId] diff --git a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts index 9f381a02c4e..075d349a528 100644 --- a/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts +++ b/src/renderer/src/store/terminals/terminal-unverified-pty-loss.ts @@ -8,7 +8,7 @@ export function createTerminalUnverifiedPtyLossActions( markUnverifiedPtyLoss: (tabId) => { set((state) => state.unverifiedPtyLossTabIds[tabId] - ? {} + ? state : { unverifiedPtyLossTabIds: { ...state.unverifiedPtyLossTabIds, [tabId]: true } } ) } From 955051ded04c3349c3f4dd995fad0a4a39300ad9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:37:13 -0700 Subject: [PATCH 03/13] fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138) * fix(codex): settle a structured send on admission, and stop minting a colliding identity Two sends could be written into the journal under one durable identity. Codex coalesces a mid-turn `turn/start` into the running turn rather than refusing it -- measured against real `codex app-server` builds 0.147.0, 0.150.1 and 0.153.4, none of which refuse and none of which fire a second `turn/started`. The dispatch path read the turn id from the turn/start response and stamped every accepted send `ordinal: 0`. Since a coalesced send gets the running turn's id back, two submissions persisted the same `providerItemId`. That string is durable, and it is the key a restore uses to match a submission against provider history, so the second message's real history row matched nothing and rendered as an extra bubble on replay. On 0.147.0 it is worse than a collision: the coalesced response returns a turn id that never starts and never completes, so the persisted key named a turn absent from history and NEITHER message could match. Identity is now minted from the echoed user message at `identityFor` -- the single point that mints the journal row's own identity -- so the settled key is by construction the one replay computes, rather than a parallel calculation that can drift. Dispatch returns `admitted` when the transport write completes; identity settles on the echo through a channel that did not previously exist for Codex. Waiters are keyed by client message id instead of being shifted off the front of an array by arrival order, and they are cleared on session close and child exit -- previously a timeout was the only thing that ever ended one. `TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured: `readCodexTurnId` returns non-null on all three, so the 10s wait never fired. The comment justifying it claimed older builds acknowledge before the id exists, which no tested build does. Three comments asserting Codex answers a mid-turn send with `turn already running` are corrected. Their only backing was a test fixture inventing that error string. The correction is factual only -- every changed line in `src/main/runtime/orchestration/` is a comment, and mid-turn delivery is still refused for both providers. Whether that policy is right is a separate question; it was resting on a false premise. Known gap, stated rather than implied: this prevents new collisions and does not repair journals already written with a colliding or phantom key. Those conversations keep duplicating on restore. Repairing them means re-matching persisted submissions against provider history and rewriting `providerItemId` -- which is what `journal-submission-reconciler.ts` is written for, and it still has no production caller. * test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes Three tests in the structured-session integration suites encoded the dispatch contract this branch replaces, and two of them pinned the defect it fixes. They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying `providerItemId: codex:::0` at send time. That ordinal was never observed; it was stamped on every accepted send, which is exactly the collision this branch removes -- a send coalesced into a running turn is answered with the running turn's id, so two submissions persisted one durable key. The visible failure was a 30s timeout rather than a failed assertion. The fake client advertised no `agent-session.pending-send-result.v1`, and without it the host holds the reply until the send settles: a shim for clients too old to render a pending bubble. The fake provider then echoed the user message with no `clientId`, so nothing could correlate that echo back to the submission, and the wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and the fake now does too, which is what makes it a model of the provider rather than a sketch of one. The identity assertion is kept rather than dropped. Each send now asserts `pending` with no identity at admission, then asserts the submission settles `accepted` at `codex:::0` once the echo lands. Same ordinal, but earned from `identityFor` on the echo -- the key a replay recomputes -- instead of guessed from the turn/start response. Ablated: removing `clientId` from the two echoes leaves both submissions `pending` and fails both assertions, so the assertion is load-bearing and not satisfied by something incidental. Both suites' client fixtures now advertise the capability set the desktop renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by a client. The older-client settlement wait keeps its own coverage in `src/main/runtime/rpc/methods/structured-agent-session.test.ts`. `structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same reason; it drives the host directly, so it never took the compatibility path, and what proves delivery there is still the turn the reacquired provider starts. The replay suite's "without dispatching it twice" property is untouched: one `turn/start` call, one replayed ledger row. * fix(codex): preserve unsettled dispatch correlations * test(codex): type the dispatch fixtures instead of asserting over them main's new casting gate (#20367 base) flags type assertions on changed lines. Replace them with checked types: the recording sink already satisfies its interface, both CodexSession fixtures are now annotated and carry real collaborators, the settlement assertion compares whole identities, and the integration helper reads submissions through the host's public journalSnapshot instead of its private session map. * fix(test): merge the duplicate doubt-reasons import the merge left behind Both sides added an import from journal-dispatch-doubt-reasons and the merge kept both statements, which the whole-repo native plugin gate refuses under --deny-warnings. * test(codex): a Fast mode turn is admitted, not accepted #20506 landed its Fast mode tests against the dispatch contract this branch replaces: a Codex send now returns admitted and settles its identity on the provider echo. The tier assertions the test exists for are untouched. --------- Co-authored-by: Merge Sim --- .../ai-vault-search/session-search-store.ts | 44 ++-- .../codex-requested-close-turn-timing.test.ts | 23 +- ...odex-structured-dispatch-admission.test.ts | 215 ++++++++++++++++++ .../codex-structured-dispatch-echo.test.ts | 108 +++++++++ .../codex/codex-structured-dispatch-echo.ts | 58 +++++ .../codex-structured-dispatch-test-support.ts | 139 +++++++++++ .../codex/codex-structured-fast-mode.test.ts | 4 +- .../codex-structured-journal-contracts.ts | 4 + .../codex/codex-structured-journal-items.ts | 7 +- ...ex-structured-journal-translation-turns.ts | 2 +- .../codex/codex-structured-provider-events.ts | 16 +- .../codex/codex-structured-session-acquire.ts | 13 +- .../codex-structured-session-adapter.test.ts | 42 +--- .../codex-structured-session-cancel.test.ts | 5 +- .../codex-structured-session-close.test.ts | 14 +- .../codex/codex-structured-session-close.ts | 3 + .../codex-structured-session-options.test.ts | 3 +- .../codex/codex-structured-session-state.ts | 15 +- src/main/codex/codex-structured-turn-start.ts | 100 ++++---- src/main/codex/codex-turn-ordinals.ts | 4 + .../journal-crash-boundary.test.ts | 9 +- .../journal-dispatch-doubt-reasons.ts | 7 - ...red-agent-session-send-idempotency.test.ts | 6 +- .../structured-agent-session-send.test.ts | 17 +- .../structured-mailbox-pointer-host.test.ts | 4 +- .../structured-session-pointer-delivery.ts | 13 +- .../structured-worker-group-addressing.ts | 4 +- ...d-agent-session-integration-replay.test.ts | 13 +- ...ructured-agent-session-integration.test.ts | 83 +++++-- ...uctured-agent-session-runtime-exit.test.ts | 6 +- .../structured-agent-session-runtime.ts | 23 +- ...ctured-agent-session-dispatch-rejection.ts | 9 +- 32 files changed, 805 insertions(+), 208 deletions(-) create mode 100644 src/main/codex/codex-structured-dispatch-admission.test.ts create mode 100644 src/main/codex/codex-structured-dispatch-echo.test.ts create mode 100644 src/main/codex/codex-structured-dispatch-echo.ts create mode 100644 src/main/codex/codex-structured-dispatch-test-support.ts diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 71215bb7174..435fa16ce8a 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -199,30 +199,32 @@ export class SessionSearchStore { files(): SessionSearchFileRow[] { return ( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The files schema and SELECT aliases define this row; REAL casts return numeric IDs or null. - this.db - .prepare( - // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. - `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, + ( + this.db + .prepare( + // Numeric stat IDs may exceed SQLite's safe INTEGER-to-number read range. + `SELECT path, CAST(dev AS REAL) AS dev, CAST(ino AS REAL) AS ino, mtime_ms AS mtimeMs, size_bytes AS sizeBytes, state, fail_count AS failCount, failed_mtime_ms AS failedMtimeMs FROM files` - ) - .all() as (Omit & { - dev: number | null - ino: number | null - })[] - ).map((row) => ({ - path: row.path, - identity: - typeof row.dev === 'number' && typeof row.ino === 'number' - ? { dev: row.dev, ino: row.ino } - : null, - mtimeMs: row.mtimeMs, - sizeBytes: row.sizeBytes, - state: row.state, - failCount: row.failCount, - failedMtimeMs: row.failedMtimeMs - })) + ) + .all() as (Omit & { + dev: number | null + ino: number | null + })[] + ).map((row) => ({ + path: row.path, + identity: + typeof row.dev === 'number' && typeof row.ino === 'number' + ? { dev: row.dev, ino: row.ino } + : null, + mtimeMs: row.mtimeMs, + sizeBytes: row.sizeBytes, + state: row.state, + failCount: row.failCount, + failedMtimeMs: row.failedMtimeMs + })) + ) } /** diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index 29039ffca3c..fbf50badaef 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -1,8 +1,10 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import { closeCodexPublishedSession } from './codex-structured-session-close' import type { CodexSession } from './codex-structured-session-state' @@ -48,17 +50,30 @@ describe('requested-close durable turn timing', () => { observedAt: 1_000 }) ).toEqual({ accepted: true }) - const session = { - connection: { close: vi.fn(async () => true) }, + const session: CodexSession = { + connection: { + pid: 4321, + closed: false, + request: async () => ({}), + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, requestedClose: false, fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - prompts: { clear: vi.fn() }, + historyPath: null, + prompts: new CodexPromptRegistry(), + options: new Map(), + reportedOptions: {}, + fastModeTierByModel: new Map(), + dispatchEchoes: createCodexDispatchEchoes(), translator - } as unknown as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() diff --git a/src/main/codex/codex-structured-dispatch-admission.test.ts b/src/main/codex/codex-structured-dispatch-admission.test.ts new file mode 100644 index 00000000000..3eece95c3fe --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-admission.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { MAX_CODEX_PENDING_DISPATCH_ECHOES } from './codex-structured-dispatch-echo' +import { + acquiredCodexAdapter, + echoUserMessage, + fakeCodexAppServer, + startTurn, + CODEX_TEST_THREAD_ID, + CODEX_TEST_USER_MESSAGE, + type LateSettlement +} from './codex-structured-dispatch-test-support' + +function send( + adapter: Awaited>, + clientMessageId: string +): Promise { + return adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: CODEX_TEST_USER_MESSAGE, + fence: 7 + }) +} + +describe('codex dispatch admission', () => { + it('admits a send queued behind a running turn and settles it when Codex echoes it', async () => { + // Measured on codex-cli 0.153.4: a `turn/start` issued while a turn runs is + // COALESCED into it -- same turn id back, no second `turn/started`, and the + // user message echoed only once the running turn reaches it. + const codex = fakeCodexAppServer({ + 'turn/start': () => ({ turn: { id: 'turn-1', status: 'inProgress' } }) + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + const outcome = await send(adapter, 'client-2') + + // No doubt: elapsed time is not evidence, so nothing invites a Retry. + expect(outcome).toEqual({ state: 'admitted' }) + expect(settlements).toEqual([]) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + + // Ordinal 1, not 0: the queued send is the SECOND user message of the turn + // it was coalesced into, which is the key a history replay computes for it. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('correlates each send by client message id, not queue order', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await send(adapter, 'client-1') + await send(adapter, 'client-2') + + // The echoes arrive in the opposite order to the sends. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u2', clientId: 'client-2' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + // Ordinals follow the ECHO order, and each one lands on the send whose + // `clientId` it carried -- not on the send that was queued in that slot. + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-2', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + }, + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 1 + } + } + ]) + }) + + it('settles nothing for a user message this session never sent', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + // A message another client sent on the same thread, and one Codex did not + // correlate at all. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-x', clientId: 'someone-else' }) + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-y' }) + + expect(settlements).toEqual([]) + }) + + it('rejects only when Codex answered and declined, and arms nothing for it', async () => { + const { CodexAppServerRequestError } = await import('./codex-app-server-connection') + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'thread not found') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + expect(await send(adapter, 'client-1')).toEqual({ + state: 'rejected', + reason: 'thread not found' + }) + + // A refused write is disarmed, so a later echo of that id settles nothing. + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('retains correlation when a request fails after its write may have landed', async () => { + const codex = fakeCodexAppServer({ + 'turn/start': () => { + throw new Error('request timed out after write') + } + }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + await expect(send(adapter, 'client-1')).rejects.toThrow('request timed out after write') + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + + expect(settlements).toEqual([ + { + sessionId: 'session-1', + clientMessageId: 'client-1', + providerIdentity: { + provider: 'codex', + threadId: CODEX_TEST_THREAD_ID, + turnId: 'turn-1', + ordinal: 0 + } + } + ]) + }) + + it('refuses overflow without discarding an older accepted send', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(await send(adapter, `client-${index}`)).toEqual({ state: 'admitted' }) + } + expect(await send(adapter, 'client-overflow')).toEqual({ + state: 'rejected', + reason: 'codex structured dispatch queue is full' + }) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u0', clientId: 'client-0' }) + expect(settlements.map(({ clientMessageId }) => clientMessageId)).toEqual(['client-0']) + }) + + it('leaves no waiter behind when the session closes', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + await adapter.closeSession('session-1') + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) + + it('leaves no waiter behind when the child exits', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'turn-1') + await send(adapter, 'client-1') + + connection.handlers.onExit?.(new Error('codex app-server exited')) + + echoUserMessage(connection, { turnId: 'turn-1', itemId: 'item-u1', clientId: 'client-1' }) + expect(settlements).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.test.ts b/src/main/codex/codex-structured-dispatch-echo.test.ts new file mode 100644 index 00000000000..58203b26964 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { + createCodexDispatchEchoes, + readCodexDispatchEcho, + MAX_CODEX_PENDING_DISPATCH_ECHOES +} from './codex-structured-dispatch-echo' + +const CODEX_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 3 +} + +describe('codex dispatch echoes', () => { + it('settles by client message id rather than arrival order', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + // Codex coalesces both sends into one turn, and the second can be echoed + // first. Queue position would settle the wrong submission here. + expect(echoes.settle('client-2')).toBe(true) + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.size).toBe(0) + }) + + it('refuses an echo this session never armed', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-from-history')).toBe(false) + expect(echoes.size).toBe(1) + }) + + it('settles a send exactly once', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + + expect(echoes.settle('client-1')).toBe(true) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('drops a send whose write never reached the provider', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.disarm('client-1') + + expect(echoes.settle('client-1')).toBe(false) + }) + + it('clears every armed send', () => { + const echoes = createCodexDispatchEchoes() + echoes.arm('client-1') + echoes.arm('client-2') + + echoes.clear() + + expect(echoes.size).toBe(0) + expect(echoes.settle('client-1')).toBe(false) + }) + + it('refuses new correlations at capacity without dropping an older send', () => { + const echoes = createCodexDispatchEchoes() + for (let index = 0; index < MAX_CODEX_PENDING_DISPATCH_ECHOES; index += 1) { + expect(echoes.arm(`client-${index}`)).toBe(true) + } + + expect(echoes.arm(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + expect(echoes.size).toBe(MAX_CODEX_PENDING_DISPATCH_ECHOES) + expect(echoes.settle('client-0')).toBe(true) + expect(echoes.settle(`client-${MAX_CODEX_PENDING_DISPATCH_ECHOES}`)).toBe(false) + }) +}) + +describe('readCodexDispatchEcho', () => { + it('reads the client message id off a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toEqual({ clientMessageId: 'client-1', providerIdentity: CODEX_IDENTITY }) + }) + + it('ignores an item that is not a user message', () => { + expect( + readCodexDispatchEcho( + { type: 'agentMessage', id: 'item-1', clientId: 'client-1' }, + CODEX_IDENTITY + ) + ).toBeNull() + }) + + it('ignores a user message Codex did not correlate', () => { + expect(readCodexDispatchEcho({ type: 'userMessage', id: 'item-1' }, CODEX_IDENTITY)).toBeNull() + }) + + it('ignores an item with no durable Codex identity', () => { + expect( + readCodexDispatchEcho( + { type: 'userMessage', id: 'item-1', clientId: 'client-1' }, + { provider: 'orca', clientMessageId: 'codex-item:thread-1:item-1' } + ) + ).toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts new file mode 100644 index 00000000000..8ea97561c59 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -0,0 +1,58 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' + +/** Sends awaiting their echo, oldest first. A send whose echo never arrives is + * retired by the journal's pending-submission recovery on exit, not from here. */ +export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256 + +/** + * Which sends this session is still waiting to hear back about, keyed by the + * client message id Codex echoes on the user message. + * + * Keyed rather than ordered on purpose: Codex coalesces a `turn/start` issued + * while a turn is running into that turn, so two sends can share one turn id and + * their echoes arrive far apart. Queue position identifies neither. + */ +export type CodexDispatchEchoes = { + /** Arms settlement for a send about to be written; false preserves older waits at capacity. */ + arm: (clientMessageId: string) => boolean + /** True once, for a send this session armed and has not yet settled. */ + settle: (clientMessageId: string) => boolean + /** Drops an armed send whose write never reached the provider. */ + disarm: (clientMessageId: string) => void + clear: () => void + readonly size: number +} + +export function createCodexDispatchEchoes(): CodexDispatchEchoes { + const armed = new Set() + return { + arm(clientMessageId) { + if (!armed.has(clientMessageId) && armed.size >= MAX_CODEX_PENDING_DISPATCH_ECHOES) { + return false + } + armed.delete(clientMessageId) + armed.add(clientMessageId) + return true + }, + settle: (clientMessageId) => armed.delete(clientMessageId), + disarm: (clientMessageId) => void armed.delete(clientMessageId), + clear: () => armed.clear(), + get size() { + return armed.size + } + } +} + +/** The user-message echo a settlement is read off, or null for any other item. */ +export function readCodexDispatchEcho( + item: { type: string; id: string } & Record, + identity: AgentJournalItemIdentity +): { clientMessageId: string; providerIdentity: AgentJournalItemIdentity } | null { + if (item.type !== 'userMessage' || identity.provider !== 'codex') { + return null + } + const clientMessageId = item.clientId + return typeof clientMessageId === 'string' && clientMessageId.length > 0 + ? { clientMessageId, providerIdentity: identity } + : null +} diff --git a/src/main/codex/codex-structured-dispatch-test-support.ts b/src/main/codex/codex-structured-dispatch-test-support.ts new file mode 100644 index 00000000000..5519ffdfdb8 --- /dev/null +++ b/src/main/codex/codex-structured-dispatch-test-support.ts @@ -0,0 +1,139 @@ +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +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 { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +export const CODEX_TEST_THREAD_ID = 'thread-abc' + +export const CODEX_TEST_USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +export type CodexTestRoute = (params: Record | undefined) => unknown + +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +export type LateSettlement = { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity +} + +/** A `codex app-server` whose turn traffic the test drives by hand. */ +export function fakeCodexAppServer(routes: Record = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + 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 + routes['thread/start'] ??= () => ({ + thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' } + }) + return { connections, openConnection, routes } +} + +/** A sink that records nothing but keeps the translator alive, which is what + * mints the identities a late settlement carries. */ +export function recordingSink(): StructuredAgentSessionEventSink { + return { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } +} + +export async function acquiredCodexAdapter(input: { + codex: ReturnType + settlements: LateSettlement[] + sink?: StructuredAgentSessionEventSink +}): Promise { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: input.codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => null, + now: () => 1_700_000_000_500, + onDispatchSettledLate: (settlement) => input.settlements.push(settlement) + }) + const identity: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID } + } + await adapter.acquire({ + identity, + fence: 7, + spawnToken: 'spawn-9', + events: input.sink ?? recordingSink() + }) + return adapter +} + +/** Codex's own echo of a user message Orca sent, inside `turnId`. */ +export function echoUserMessage( + connection: FakeConnection, + input: { turnId: string; itemId: string; clientId?: string; threadId?: string } +): void { + connection.handlers.onNotification?.('item/started', { + threadId: input.threadId ?? CODEX_TEST_THREAD_ID, + turn: { id: input.turnId }, + item: { + type: 'userMessage', + id: input.itemId, + ...(input.clientId ? { clientId: input.clientId } : {}) + } + }) +} + +export function startTurn(connection: FakeConnection, turnId: string): void { + connection.handlers.onNotification?.('turn/started', { + threadId: CODEX_TEST_THREAD_ID, + turn: { id: turnId } + }) +} diff --git a/src/main/codex/codex-structured-fast-mode.test.ts b/src/main/codex/codex-structured-fast-mode.test.ts index 917133c7543..3029bff280b 100644 --- a/src/main/codex/codex-structured-fast-mode.test.ts +++ b/src/main/codex/codex-structured-fast-mode.test.ts @@ -112,7 +112,9 @@ describe('Codex structured Fast mode dispatch', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ state: 'accepted' }) + // `admitted`, not `accepted`: a Codex send now settles its identity on + // the provider echo. What this test pins is the tier the turn carries. + ).resolves.toMatchObject({ state: 'admitted' }) expect( codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ serviceTier: 'default' }) diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index d7a902c9484..4114f9b0355 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' 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 type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -15,6 +16,9 @@ export type CodexJournalTranslatorDeps = { turnId?: string | null ) => void clearPromptTurn?: (threadId: string, turnId: string) => void + /** Settles a send's identity off the echoed user message, using the very + * identity the journal row carries so a replay computes the same key. */ + onUserMessageEcho?: (clientMessageId: string, identity: AgentJournalItemIdentity) => void primaryThreadId?: () => string | null subagentExecutions?: CodexSubagentExecutions coalesceMs?: number diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 62091580da3..b5ab9ad90e1 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -29,6 +29,7 @@ import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structu import type { CodexActiveJournalItem } from './codex-structured-journal-settlement' import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -40,7 +41,7 @@ export class CodexJournalItems { constructor( private readonly deps: Pick< CodexJournalTranslatorDeps, - 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' + 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'onUserMessageEcho' > & { maxMetadataBytes?: number }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void @@ -78,6 +79,10 @@ export class CodexJournalItems { const identity = this.identityFor(event.threadId, turnId, item) // Count echoes for stable resume ordinals, but user bubbles come from submissions. if (source === 'live' && item.type === 'userMessage') { + const echo = readCodexDispatchEcho(item, identity) + if (echo) { + this.deps.onUserMessageEcho?.(echo.clientMessageId, echo.providerIdentity) + } return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } if (item.type === 'contextCompaction' && event.method === 'item/started') { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index d1cd7ca2884..e36322bca0c 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -6,7 +6,7 @@ import type { } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { CODEX_USER_MESSAGE_ORDINAL } from './codex-structured-turn-start' +import { CODEX_USER_MESSAGE_ORDINAL } from './codex-turn-ordinals' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts index 989232ff1b2..69b4cb0d392 100644 --- a/src/main/codex/codex-structured-provider-events.ts +++ b/src/main/codex/codex-structured-provider-events.ts @@ -3,7 +3,7 @@ import { disposeCodexServerRequest } from './codex-server-request-disposition' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import * as codexRewind from './codex-structured-rewind' import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' -import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { readCodexThreadId } from './codex-structured-thread-facts' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' type EmitCodexEvent = ( @@ -41,10 +41,9 @@ export function deliverCodexNotification( return { accepted: true } } const threadId = readCodexThreadId(params) ?? session.threadId - const turnId = - method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null - const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined - const admission = emit(session, { + // Dispatch identity settles on the user-message echo inside the translator, + // which is where the ordinal a replay will compute is minted. + return emit(session, { type: 'notification', sessionId, threadId, @@ -52,13 +51,6 @@ export function deliverCodexNotification( params, ...(observedAt !== undefined ? { observedAt } : {}) }) - if (method === 'turn/started' && threadId === session.threadId) { - if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) { - session.turnIdWaiters.shift() - turnWaiter?.(turnId) - } - } - return admission } export function deliverCodexServerRequest( diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b104c559af2..c191e675973 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -10,6 +10,7 @@ import { } from './codex-structured-acquisition-lifecycle' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openCodexAppServerConnection } from './codex-app-server-connection' import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' @@ -81,6 +82,7 @@ export async function acquireCodexStructuredSession(input: { ? acquireInput.identity.providerHandle.threadId : null const subagentExecutions = new CodexSubagentExecutions() + const dispatchEchoes = createCodexDispatchEchoes() const translator = acquireInput.events ? createCodexJournalTranslator({ sink: acquireInput.events, @@ -90,7 +92,14 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, bindPromptItemId: (journalItemId, threadId, promptKey, turnId) => acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId), - clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId) + clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId), + onUserMessageEcho: (clientMessageId, providerIdentity) => { + // Only a send THIS session admitted; an echo from history restore or + // another client names no submission of ours to settle. + if (dispatchEchoes.settle(clientMessageId)) { + deps.onDispatchSettledLate?.({ sessionId, clientMessageId, providerIdentity }) + } + } }) : null const open = deps.openConnection ?? openCodexAppServerConnection @@ -230,7 +239,7 @@ export async function acquireCodexStructuredSession(input: { options, reportedOptions: reportedCodexThreadOptions(opened), fastModeTierByModel: fastModeCatalog?.fastModeTierByModel ?? new Map(), - turnIdWaiters: [], + dispatchEchoes, translator, backgroundTasks: new CodexBackgroundTaskTracker(opened.threadId, subagentExecutions), forceCloseUnexpected: (reason) => diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index b32c7e69a1b..d32c3013b6c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -315,7 +315,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) describe('CodexStructuredSessionAdapter.dispatch', () => { - it('accepts a turn Codex names in its response', async () => { + it('admits a send as soon as Codex owns it', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) const adapter = await acquired(codex) @@ -334,10 +334,9 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } - }) + // Identity is not knowable here: a send coalesced into a running turn shares + // that turn's id, so the echo settles which message landed where. + expect(outcome).toEqual({ state: 'admitted' }) expect(codex.connections[0].calls[1].params).toEqual({ threadId: THREAD_ID, clientUserMessageId: 'client-1', @@ -349,7 +348,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { }) }) - it('accepts a turn named only by the notification that raced the ack', async () => { + it('admits a send on a build whose turn/start answers before the turn is named', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) @@ -368,8 +367,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toMatchObject({ state: 'accepted' }) - expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) + expect(outcome).toEqual({ state: 'admitted' }) expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) }) @@ -393,10 +391,7 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { fence: 7 }) - expect(outcome).toEqual({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } - }) + expect(outcome).toEqual({ state: 'admitted' }) // 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([ @@ -405,29 +400,6 @@ describe('CodexStructuredSessionAdapter.dispatch', () => { ]) }) - 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': () => { diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index 2ea81d44786..1ac807a5ccd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -257,9 +257,8 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { body: USER_MESSAGE, fence: 7 }) - ).resolves.toMatchObject({ - state: 'accepted', - providerIdentity: { turnId: 'turn-2' } + ).resolves.toEqual({ + state: 'admitted' }) }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 58c5bc5f50a..17f3aecf671 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import type { @@ -11,6 +12,7 @@ import { } from './codex-structured-session-adapter' import { handleCodexSessionExit } from './codex-structured-session-close' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' import type { CodexSession } from './codex-structured-session-state' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' @@ -84,13 +86,13 @@ describe('Codex structured session close lifecycle', () => { respondWithError: () => {}, close: async () => true } - const prompts = { clear: vi.fn() } as unknown as CodexSession['prompts'] + const prompts = new CodexPromptRegistry() + const clearPrompts = vi.spyOn(prompts, 'clear') const translator = { handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }), dispose: vi.fn() } as unknown as NonNullable - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal supplies every CodexSession field the close path reads; the rest are unused by it. - const session = { + const session: CodexSession = { connection, backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), ended: false, @@ -103,9 +105,9 @@ describe('Codex structured session close lifecycle', () => { options: new Map(), reportedOptions: {}, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator - } as CodexSession + } const sessions = new Map([['session-1', session]]) const onEvent = vi.fn() @@ -120,7 +122,7 @@ describe('Codex structured session close lifecycle', () => { }) ).toBe(true) expect(session.ended).toBe(true) - expect(prompts.clear).toHaveBeenCalledOnce() + expect(clearPrompts).toHaveBeenCalledOnce() expect(onEvent).toHaveBeenCalledOnce() expect(translator.dispose).toHaveBeenCalledOnce() expect(onEvent.mock.calls[0]?.[0]).toMatchObject({ diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index af814c51d9b..2058f86ce85 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -47,6 +47,9 @@ export function handleCodexSessionExit(input: { event.settlementRetryRequired = true } session.ended = true + // Nothing can echo for this child any more; the journal's pending-submission + // recovery is what settles the sends these were armed for. + session.dispatchEchoes.clear() session.backgroundTasks.clear() input.onBackgroundTasksChanged?.(input.sessionId, null) session.unbindReadingControl?.() diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 4e9abfc17b5..0a8bf41688c 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -1,3 +1,4 @@ +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' @@ -34,7 +35,7 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, fastModeTierByModel: new Map(), - turnIdWaiters: [], + dispatchEchoes: createCodexDispatchEchoes(), translator: null } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 625e222ecfb..df66d436bf1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -1,4 +1,7 @@ -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' import { randomUUID } from 'node:crypto' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import type { @@ -6,6 +9,7 @@ import type { openCodexAppServerConnection } from './codex-app-server-connection' import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexJournalTranslator } from './codex-structured-journal-translation' @@ -58,6 +62,12 @@ export type CodexStructuredSessionAdapterDeps = { sessionId: string, state: AgentSessionBackgroundTaskState | null ) => void + /** Identity for a send admitted earlier, once Codex echoes the user message. */ + onDispatchSettledLate?: (input: { + sessionId: string + clientMessageId: string + providerIdentity: AgentJournalItemIdentity + }) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise mintLinkId?: () => string @@ -94,7 +104,8 @@ export type CodexSession = { } /** Exact provider-advertised Fast request value for each discovered model. */ fastModeTierByModel: Map - turnIdWaiters: ((turnId: string) => void)[] + /** Sends whose identity is still to be settled by the provider echo. */ + dispatchEchoes: CodexDispatchEchoes translator: CodexJournalTranslator | null /** Ephemeral roster behind the background-tasks strip; never durable state. */ backgroundTasks: CodexBackgroundTaskTracker diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index e6a53925fc6..c88370adf05 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -6,21 +6,16 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { readCodexTurnId } from './codex-structured-thread-facts' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { DISPATCH_REJECTED_CODEX_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' -// 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 +// Writing a Codex turn and learning which message landed where, which are not +// the same event. `turn/start` answers as soon as Codex owns the message, but a +// message issued while a turn is running is COALESCED into that turn: the same +// turn id comes back, no second `turn/started` fires, and the user message is +// echoed only when the running turn reaches it. So the response proves +// admission and nothing about identity, which the echo settles later. /** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise * become an arbitrary client-controlled `turn/start` parameter. */ @@ -38,16 +33,14 @@ 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. */ +/** The session state one turn needs. */ export type CodexTurnHost = { connection: Pick threadId: string options: Map reportedOptions?: { model?: string } fastModeTierByModel: ReadonlyMap - turnIdWaiters: ((turnId: string) => void)[] + dispatchEchoes: CodexDispatchEchoes } function turnInputFor(body: AgentJournalMessageItem): Record[] { @@ -92,69 +85,54 @@ function codexTurnOptions(host: CodexTurnHost): Record { } /** - * 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. + * Hands one submission to Codex. False means the bounded correlation window + * refused it before the write; otherwise resolves when Codex has taken it. */ 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), - ...codexTurnOptions(host) - }, - { 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) - } +): Promise { + // Armed before the write: the echo can land while the response is in flight. + if (!host.dispatchEchoes.arm(input.clientMessageId)) { + return false } + await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + ...codexTurnOptions(host) + }, + { timeoutMs: input.timeoutMs } + ) + return true } /** - * 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. + * One submission's outcome as the wire must read it: admitted means Codex owns + * the message and its identity settles on the echo, rejected is Codex answering + * and declining. Elapsed time is never evidence here, because the wait a + * coalesced send would face is bounded only by the running turn. */ 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 }) + if (!(await startCodexTurn(session, { ...input, timeoutMs }))) { + return { state: 'rejected', reason: DISPATCH_REJECTED_CODEX_QUEUE_FULL } + } } catch (error) { if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + // Codex answered and declined, so no echo for this write can arrive. + session.dispatchEchoes.disarm(input.clientMessageId) return { state: 'rejected', reason: (error as Error).message } } + // A timeout or transport failure can happen after the frame was written. + // Keep the correlation armed so a later echo can prove delivery. throw error } - return turnId === null - ? { state: 'unknown', reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED } - : { - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: session.threadId, - turnId, - ordinal: CODEX_USER_MESSAGE_ORDINAL - } - } + return { state: 'admitted' } } diff --git a/src/main/codex/codex-turn-ordinals.ts b/src/main/codex/codex-turn-ordinals.ts index 89ed72666db..7e21b900d96 100644 --- a/src/main/codex/codex-turn-ordinals.ts +++ b/src/main/codex/codex-turn-ordinals.ts @@ -3,6 +3,10 @@ import { digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +/** Codex records the user message first in a turn, so a restored submission is + * ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + /** Maximum forgotten turn keys retained for late-frame reconciliation. */ export const MAX_CODEX_TURN_ORDINAL_ENTRIES = 256 export const MAX_CODEX_TURN_ORDINAL_BYTES = 512 * 1024 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 index c8d48ef336b..d142672f8e5 100644 --- 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 @@ -16,7 +16,6 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from './journal-dispatch-doubt-reasons' import { dispatchWriteFailureReason } from '../../../shared/structured-agent-session-dispatch-rejection' import { digestPayload } from './journal-payload-bounds' import { @@ -55,6 +54,8 @@ function userMessage(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } +const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time' + const journals = createTrackedJournalOpener() async function open() { @@ -196,6 +197,8 @@ describe('crash between provider accept and journal commit', () => { expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) }) + // Only an older Orca minted this reason -- Codex now settles a send on the + // provider echo -- but rows written under it still come back from disk. it('keeps a codex turn it could not name in doubt, never rejected', async () => { const journal = await open() await journal.appendSubmission({ @@ -207,7 +210,7 @@ describe('crash between provider accept and journal commit', () => { await journal.resolveDispatch({ clientMessageId: 'cm_codex_unnamed', state: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, fence: 1 }) @@ -218,7 +221,7 @@ describe('crash between provider accept and journal commit', () => { // and it may never become a rejection, which would license a re-delivery. expect(restarted.submissions()[0]).toMatchObject({ dispatchState: 'unknown', - reason: DISPATCH_DOUBT_CODEX_TURN_UNNAMED, + reason: LEGACY_CODEX_TURN_UNNAMED, recovered: true }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index dbd56132100..206bd19e3ef 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -23,13 +23,6 @@ export const DISPATCH_DOUBT_PERSISTENCE_FAILED = 'dispatch_result_persistence_fa /** The operation tombstone survived recovery but its journal submission did not. */ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missing' -/** Codex owns a turn it started but did not name, because its turn-start still - * settles on a deadline. The turn IS running, so this must never be treated as - * proof of non-delivery. Delete it once Codex settles on the app-server's - * turn-start response instead. */ -export const DISPATCH_DOUBT_CODEX_TURN_UNNAMED = - 'codex app-server started a turn it did not name in time' - /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' 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 index c0b56aa7d14..477ed785918 100644 --- 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 @@ -8,7 +8,6 @@ import { structuredAgentSessionPayloadFingerprint } from '../../../shared/struct import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { DISPATCH_DOUBT_CODEX_TURN_UNNAMED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' const journals = createTrackedJournalOpener() @@ -39,7 +38,10 @@ describe('structured send idempotency', () => { it.each([ ['a refused write', 'provider_write_failed: broken pipe'], ['a dead host', 'host_restarted_before_acknowledgement'], - ['a codex turn it could not name', DISPATCH_DOUBT_CODEX_TURN_UNNAMED] + [ + 'a codex turn an older Orca could not name', + 'codex app-server started a turn it did not name in time' + ] ])('never puts an unknown back on the wire after %s', async (_case, reason) => { const body: AgentJournalMessageItem = { kind: 'message', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index e2f294f40f7..f2d1ab12230 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -6,7 +6,10 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_EXITED, + DISPATCH_DOUBT_SUBMISSION_MISSING +} from '../agent-session-journal/journal-dispatch-doubt-reasons' import { accepted, attach, @@ -169,13 +172,15 @@ describe('send', () => { expect(state.ok && state.page.submissions).toHaveLength(2) }) - it('refuses to redeliver a retry for a turn the provider already owns', async () => { + it('refuses to redeliver a retry for a message the provider may already hold', async () => { await attach() + // A dead child ends the wait without proving non-delivery: the message was + // already written to that child's stdin. dispatch.mockImplementationOnce(async () => ({ state: 'unknown' as const, - reason: 'codex app-server started a turn it did not name in time' + reason: DISPATCH_DOUBT_PROVIDER_EXITED })) - const body = hostTestMessage('a turn codex owns but did not name') + const body = hostTestMessage('a message the provider may already hold') const params = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, params) @@ -183,8 +188,8 @@ describe('send', () => { ok: true, value: { submission: { dispatchState: 'unknown' } } }) - // The turn is running; a second delivery would be a duplicate, so Retry - // replays the recorded outcome instead of re-sending. + // No `unknown` is re-delivered under its own id, whatever its reason says, + // so Retry replays the recorded outcome instead of writing again. await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fe08e5f808d..91bf1158e07 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -42,8 +42,8 @@ describe('structured mailbox pointer host', () => { // The defect this pins: a running turn is announced by ONE lifecycle item, and settlement // tombstones it rather than rewriting it. A long tool-calling turn pushes that item arbitrarily // far from the tail, so any page-sized read reports a busy worker as idle — and the pointer is - // then delivered mid-turn, which Codex answers with `turn already running` and Claude settles - // `unknown` while the message is really queued. + // then delivered mid-turn, which Codex coalesces into the running turn and Claude queues behind + // it -- either way folded into work already in flight rather than read as a new instruction. const items = [runningTurn(), ...transcript(500)] hostRef.current = { journalSnapshot: () => ({ items }) } expect(createStructuredMailboxPointerHost().readGateFacts('s1')).toEqual({ diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 272d7799947..dd8ccf64f71 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -81,11 +81,14 @@ export function structuredSessionGateFacts( * Decide whether the nudge may be sent right now. * * Mid-turn delivery is refused for both providers rather than delegated to - * them: Codex answers a mid-turn `turn/start` with `turn already running`, and - * Claude accepts the frame but cannot acknowledge it inside the dispatch ack - * window, settling `unknown` while the message is really queued. Waiting for - * the turn to settle is the one contract that holds for both, and it preserves - * orchestration's existing idle-edge-only delivery policy. + * them. Neither refuses the frame: Codex COALESCES a mid-turn `turn/start` into + * the running turn -- measured on codex-cli 0.147.0, 0.150.1 and 0.153.4, none + * of which refuse it and none of which fire a second `turn/started` -- and + * Claude queues it behind the turn. Both therefore + * fold the nudge into work already in flight, where it reads as part of the + * running turn rather than a new instruction. Waiting for the turn to settle is + * the one contract that holds for both, and it preserves orchestration's + * existing idle-edge-only delivery policy. */ export function decideStructuredPointerDelivery(input: { refusal: AgentSessionPtyWriteRefusal diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.ts index 8abad118de7..168bd870118 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.ts @@ -49,8 +49,8 @@ export function listAddressableStructuredWorkers(): OrchestrationAddressableAgen * A structured worker's agent status, in the vocabulary `@idle` already matches on. * * Null when the session cannot be read: unknown must not read as idle, or a broadcast to `@idle` - * would wake a worker mid-turn — which Codex answers with `turn already running` and Claude queues - * behind the running turn. + * would wake a worker mid-turn — which Codex coalesces into the running turn and Claude queues + * behind it. */ export function structuredWorkerAgentStatus(sessionId: string): string | null { const facts = readStructuredSessionGateFacts(sessionId) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 990aa293ee4..4fa390be0fb 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -17,7 +17,10 @@ import type { } 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 { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' @@ -37,10 +40,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index f5a59033d73..219b132566e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -16,8 +16,14 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' 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 { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../shared/agent-session-journal-types' import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent @@ -43,10 +49,16 @@ const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' +// The capability set the desktop renderer advertises. Without the pending-send +// one the host holds the reply until the send settles, which is a shim for +// clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -269,6 +281,13 @@ function textOf(item: AgentJournalRenderItem): string { : '' } +/** The durable submission row, which settlement rewrites after the send returns. */ +function submissionOf(clientMessageId: string): AgentJournalSubmission | undefined { + return getStructuredAgentSessionHost() + ?.journalSnapshot(SESSION) + .submissions.find((entry) => entry.clientMessageId === clientMessageId) +} + async function historyPage( direction: 'tail' | 'before' | 'after', extra: Record = {} @@ -438,18 +457,25 @@ describe('a structured codex session over agentSession.*', () => { envelope: envelope('agentSession.send', { body }, created.fence), body }) - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `codex:${THREAD}:${TURN}:0` - }) + // Admission, not identity. `turn/start` proves Codex owns the message, but a + // send coalesced into a running turn is answered with that turn's id, so + // which message landed where is knowable only from the echo. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } }) codex.notify('turn/started', { turn: { id: TURN } }) + // Codex echoes the message back carrying the `clientId` it was sent under, + // which is the only thing that names which submission this row settles. codex.notify('item/completed', { - item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + 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.' }) @@ -459,6 +485,15 @@ describe('a structured codex session over agentSession.*', () => { await drainStreamedEvents() expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + // The echo is the first item of this turn, so the settled key is ordinal 0 — + // minted by the same `identityFor` a history replay computes with, rather + // than guessed from the turn/start response. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) }) it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { @@ -513,12 +548,10 @@ describe('a structured codex session over agentSession.*', () => { 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` - }) + // Codex took the message, so the submission is pending rather than + // "delivery unconfirmed" — it carries no identity yet, because the response + // to a coalesced send names the running turn rather than this message. + expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params: { @@ -531,14 +564,28 @@ describe('a structured codex session over agentSession.*', () => { // ── 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 echoes the user message back as ordinal 0 of the turn, carrying the + // `clientId` it was sent under. That echo settles the submission's identity, + // and 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' }] } + item: { + type: 'userMessage', + id: 'item-0', + clientId: sent.clientMessageId, + content: [{ type: 'text', text: 'list files' }] + } }) await drainStreamedEvents() expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) + // Settled from the echo's own journal identity, so it is by construction the + // key a replay recomputes for this row. + await vi.waitFor(() => + expect(submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + ) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 506a45ae821..7a3736b9009 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -121,9 +121,13 @@ describe('structured session runtime provider-exit wiring', () => { }) } + // `pending` is this send's real answer now, not a weaker one: admission settles + // when the transport takes the frame, and identity arrives later on the + // provider's echo. What proves the message reached the REACQUIRED provider is + // the turn it starts below, which is what this test exists to check. await expect( host.send({ callerKey: 'runtime-test' }, { envelope, body }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) expect(turn).toBe(1) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index fbc13b58fca..e437739a58b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,6 +7,7 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -246,6 +247,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host?.settleLateDispatch(settlement).catch((error) => + deps.onError?.({ + scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -257,6 +270,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), + onDispatchSettledLate, onEvent: (event) => { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return @@ -298,14 +312,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishBackgroundTaskState(sessionId, state), - onDispatchSettledLate: (settlement) => { - void host?.settleLateDispatch(settlement).catch((error) => - deps.onError?.({ - scope: `structured-agent-session-late-settlement:${settlement.sessionId}`, - error - }) - ) - }, + onDispatchSettledLate, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) }) diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 9ea733e5832..0b2aecfb67e 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -20,8 +20,11 @@ export const DISPATCH_REJECTED_WRITE_FAILED = 'provider_write_failed' -/** Local admission refused the frame before any transport was involved. */ +/** Local admission refused the frame before any transport was involved. Two + * strings rather than one provider-neutral marker because both are already + * durable journal reasons; rewording either would relabel rows on disk. */ export const DISPATCH_REJECTED_QUEUE_FULL = 'claude structured dispatch queue is full' +export const DISPATCH_REJECTED_CODEX_QUEUE_FULL = 'codex structured dispatch queue is full' export function dispatchWriteFailureReason(error: unknown): string { const detail = error instanceof Error ? error.message : String(error) @@ -45,6 +48,8 @@ export function dispatchRejectionWasTransportWriteFailure( */ export function dispatchRejectionReasonIsInternal(reason: string | null | undefined): boolean { return ( - dispatchRejectionWasTransportWriteFailure(reason) || reason === DISPATCH_REJECTED_QUEUE_FULL + dispatchRejectionWasTransportWriteFailure(reason) || + reason === DISPATCH_REJECTED_QUEUE_FULL || + reason === DISPATCH_REJECTED_CODEX_QUEUE_FULL ) } From 59d29af402855dd5d1ab60c253cf0f5cf4f56487 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:43:51 -0700 Subject: [PATCH 04/13] test: add a verified OMP native-chat mock scenario (#20655) Co-authored-by: plotarmordev --- mobile/README.md | 1 + .../mock-server-native-chat-scenario.ts | 75 ++++++++++++++++--- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 64f1081b73c..e78c2c410bf 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -184,6 +184,7 @@ Connect from the app using endpoint `ws://localhost:6768` and token `mock-device ### Environment variables - `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures. +- `MOCK_CHAT_AGENT=omp` — with `MOCK_NATIVE_CHAT=1`, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits `transcriptPath` to exercise legacy-hook readability discovery; current OMP hooks may report a path. - `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair. ### Scenario control files diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts index d2f65e0b635..b9d18348f7b 100644 --- a/mobile/scripts/mock-server-native-chat-scenario.ts +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WebSocket } from 'ws' import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { NativeChatMessage } from '../../src/shared/native-chat-types' import type { RuntimeMobileSessionTabsResult, RuntimeMobileSessionTerminalClientTab @@ -25,6 +26,10 @@ const TAB_ID = 'chat-tab-1' const SESSION_ID = 'mock-chat-session' const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') +// Exercise legacy OMP hooks without a transcript path; current hooks may include one. +const CHAT_AGENT = process.env.MOCK_CHAT_AGENT === 'omp' ? 'omp' : 'claude' +const CHAT_TITLE = CHAT_AGENT === 'omp' ? 'OMP' : 'Claude Code' +const TRANSCRIPT_START = Date.now() - 1000 * 60 * 5 function readControl(file: string): string { try { @@ -41,28 +46,27 @@ const agentStatus: AgentStatusEntry = { prompt: '', updatedAt: Date.now(), stateStartedAt: Date.now(), - agentType: 'claude', + agentType: CHAT_AGENT, paneKey: `${TAB_ID}:leaf-1`, terminalHandle: TERMINAL_HANDLE, stateHistory: [], - providerSession: { - key: 'session_id', - id: SESSION_ID, - transcriptPath: TRANSCRIPT_PATH - } + providerSession: + CHAT_AGENT === 'omp' + ? { key: 'session_id', id: SESSION_ID } + : { key: 'session_id', id: SESSION_ID, transcriptPath: TRANSCRIPT_PATH } } function buildTab(): RuntimeMobileSessionTerminalClientTab { return { type: 'terminal', id: TAB_ID, - title: 'Claude Code', + title: CHAT_TITLE, parentTabId: TAB_ID, leafId: 'leaf-1', ptyId: 'pty-1', status: 'ready', terminal: TERMINAL_HANDLE, - launchAgent: 'claude', + launchAgent: CHAT_AGENT, agentStatus, viewMode: 'chat', isActive: true @@ -104,6 +108,51 @@ function worktreeOf(request: RpcRequest): string { return typeof raw === 'string' ? raw : 'id:mock-worktree' } +// Why: shapes mirror what the runtime's omp decoder emits for a real session +// (thinking→text on the assistant turn, toolCall blocks, toolResult turns), so +// the phone exercises the same render path a live omp pane would. +function mockTranscript(): NativeChatMessage[] { + if (CHAT_AGENT !== 'omp') { + return [] + } + const t = TRANSCRIPT_START + return [ + { + id: 'omp-1', + role: 'user', + blocks: [{ type: 'text', text: 'why is my deploy failing?' }], + timestamp: t, + source: 'transcript' + }, + { + id: 'omp-2', + role: 'assistant', + blocks: [ + { type: 'text', text: 'Let me check the deploy logs first.' }, + { type: 'tool-call', name: 'bash', input: { command: 'kubectl get pods' } } + ], + timestamp: t + 1000, + source: 'transcript' + }, + { + id: 'omp-3', + role: 'tool', + blocks: [{ type: 'tool-result', output: 'api-7f9c 0/1 CrashLoopBackOff' }], + timestamp: t + 2000, + source: 'transcript' + }, + { + id: 'omp-4', + role: 'assistant', + blocks: [ + { type: 'text', text: 'The API pod is crash-looping. Check its logs with kubectl logs.' } + ], + timestamp: t + 3000, + source: 'transcript' + } + ] +} + // Why: unsubscribe correlates by worktree, not request id, and a socket that // navigates A->B->A would otherwise stack one push loop per subscribe. const tabsPushLoops = new Map>>() @@ -144,7 +193,7 @@ type Respond = (response: RpcResponse) => void type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse type Failure = (id: string, code: string, message: string) => RpcResponse -/** Mock backend for the native-chat surface: session tabs, an empty transcript +/** Mock backend for the native-chat surface: session tabs, a fixture transcript * snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1 * because it replaces the default terminal fixtures. No transcript or terminal * output frames are pushed. Returns false for methods it does not own. */ @@ -194,7 +243,7 @@ export function handleMockNativeChatRequest( const entry = (handle: string) => ({ handle, worktreeId, - title: 'Claude Code', + title: CHAT_TITLE, isActive: true, hasRunningProcess: true }) @@ -209,11 +258,13 @@ export function handleMockNativeChatRequest( } case 'nativeChat.subscribe': - respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true)) + respond( + success(request.id, { type: 'snapshot', messages: mockTranscript(), hasMore: false }, true) + ) return true case 'nativeChat.readSession': - respond(success(request.id, { messages: [], hasMore: false })) + respond(success(request.id, { messages: mockTranscript(), hasMore: false })) return true case 'terminal.subscribe': { From 3632311d0b8ae24a9bc5a9d50bb66ca26b25dd18 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:18 -0700 Subject: [PATCH 05/13] fix(omp): preserve status after terminal title owner rewrite (#20610) Validated and independently reviewed OMP integration fix. Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com> --- .../omp-native-title-win32.meta.json | 13 +++++ .../__fixtures__/omp-native-title-win32.txt | 1 + .../worktree-title-derived-agent-rows.test.ts | 20 ++++++++ .../terminal-title-tracker-parity.test.ts | 14 +++++ src/shared/agent-title-identity.ts | 5 ++ src/shared/agent-title-owner.ts | 5 ++ src/shared/omp-owner-state-title.test.ts | 51 +++++++++++++++++++ src/shared/pi-compatible-synthetic-title.ts | 6 +++ src/shared/pi-state-title-marker.ts | 31 +++++++++-- tests/e2e/omp-title-marker.spec.ts | 43 ++++++++++++++++ tests/tools/omp-native-title-capture.mjs | 18 +++++++ 11 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/__fixtures__/omp-native-title-win32.meta.json create mode 100644 src/main/runtime/__fixtures__/omp-native-title-win32.txt create mode 100644 src/shared/omp-owner-state-title.test.ts create mode 100644 tests/e2e/omp-title-marker.spec.ts create mode 100644 tests/tools/omp-native-title-capture.mjs diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json new file mode 100644 index 00000000000..8594baa7630 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.meta.json @@ -0,0 +1,13 @@ +{ + "capturedAt": "2026-09-14T11:25:01.730Z", + "platform": "darwin", + "command": [ + "bun", + "tests/tools/omp-native-title-capture.mjs", + "" + ], + "cols": 100, + "rows": 30, + "note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/omp-native-title-win32.txt b/src/main/runtime/__fixtures__/omp-native-title-win32.txt new file mode 100644 index 00000000000..ac67e688dce --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-native-title-win32.txt @@ -0,0 +1 @@ +]0;π : Run a long task]0;π : release | π : note | OMP ! action required ✦]0;π > Run a long task]0;π > release | π : note | OMP ! action required ✦]0;π ! Run a long task]0;π ! release | π : note | OMP ! action required ✦ \ No newline at end of file diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index c5de2eb1813..b0e7340f373 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -90,6 +90,26 @@ describe('buildTitleDerivedAgentRows', () => { ]) }) + it.each([ + [':', 'working'], + ['>', 'idle'], + ['!', 'waiting'] + ])('retains hook-less OMP rows for owner marker %s', (marker, state) => { + const title = `OMP ${marker} Run a long task` + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'omp' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: title } }, + ptyIdsByTabId: { 'tab-1': ['pty-omp'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([ + ['omp', state, title] + ]) + }) + it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent: 'pi' })], diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 09126e91c6b..dac6bbaf326 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' // Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY // title tracker in main alongside the renderer transport's byte parser. Both // must derive IDENTICAL ordered title/status facts from the same bytes, or @@ -103,6 +104,19 @@ describe('main title tracker parity with the renderer transport processor', () = vi.useRealTimers() }) + it('agrees on captured OMP native frames before and after owner rebranding', () => { + const captured = readFileSync( + new URL('../../../../main/runtime/__fixtures__/omp-native-title-win32.txt', import.meta.url), + 'utf8' + ) + feedBoth(paths, captured) + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.some((event) => event.kind === 'became-working')).toBe(true) + expect(paths.main.events.some((event) => event.kind === 'became-idle')).toBe(true) + feedBoth(paths, captured.replaceAll(']0;π', ']0;OMP')) + expect(paths.main.events).toEqual(paths.renderer.events) + }) + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's // trailing idle title. A last-title reader sees only the idle title and diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 2b5194bfda8..949768e03b5 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -1,3 +1,4 @@ +import { getPiStateTitleBrand } from './pi-state-title-marker' import { AGY_AGENT_NAME_RE, CLAUDE_IDLE, @@ -67,6 +68,10 @@ function computeAgentLabel(title: string): string | null { ) { return 'Claude Code' } + const piStateBrand = getPiStateTitleBrand(title) + if (piStateBrand) { + return piStateBrand + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts index 2526c94572f..df0a668621a 100644 --- a/src/shared/agent-title-owner.ts +++ b/src/shared/agent-title-owner.ts @@ -1,3 +1,4 @@ +import { rebrandPiStateTitle } from './pi-state-title-marker' import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' import type { AgentStatusEntry, AgentType } from './agent-status-types' import { @@ -157,6 +158,10 @@ export function normalizeCompatibleAgentTitleForOwner( ) { return title } + const stateTitle = rebrandPiStateTitle(title, ownerProfile.workingLabel) + if (stateTitle !== null) { + return stateTitle + } // Why: a π-branded title is the agent's own semantic session title (`π > - `; // Orca's injected extension writes the same shape). Swap only the BRAND for the owner's label // so the pane still reads as its launch owner (#6689, #7633, #9077) without discarding the diff --git a/src/shared/omp-owner-state-title.test.ts b/src/shared/omp-owner-state-title.test.ts new file mode 100644 index 00000000000..c06e4dbfe78 --- /dev/null +++ b/src/shared/omp-owner-state-title.test.ts @@ -0,0 +1,51 @@ +import { getPiCompatibleTitleSeparatorStatus } from './pi-compatible-synthetic-title' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection' +import { normalizeCompatibleAgentTitleForOwner } from './agent-title-owner' +import { clearPiStateWorkingMarker } from './pi-state-title-marker' + +const transcript = readFileSync( + join(__dirname, '..', 'main', 'runtime', '__fixtures__', 'omp-native-title-win32.txt'), + 'utf8' +) +// oxlint-disable-next-line no-control-regex -- The fixture retains actual OSC control bytes. +const titles = [...transcript.matchAll(/\x1b\]0;([^\x07]+)\x07/g)].map((match) => match[1]) + +describe('owner-rewritten OMP titles from captured upstream output', () => { + it('contains the six upstream state frames', () => expect(titles).toHaveLength(6)) + it.each( + titles.map((title, index) => ({ + title, + state: index < 2 ? 'working' : index < 4 ? 'idle' : 'permission' + })) + )('preserves $state and label for $title', ({ title, state }) => { + for (const prefix of ['', 'zsh | ', 'tmux: ']) { + const wrapped = prefix + title + expect(detectAgentStatusFromTitle(wrapped)).toBe(state) + const owned = normalizeCompatibleAgentTitleForOwner(wrapped, 'omp', { ownerIsLaunch: true }) + expect(owned).toBe(prefix + title.replace('π', 'OMP')) + expect(getAgentLabel(owned)).toBe('OMP') + expect(detectAgentStatusFromTitle(owned)).toBe(state) + expect(getPiCompatibleTitleSeparatorStatus(owned)).toBe(state) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'omp')).toBe(owned) + expect(normalizeCompatibleAgentTitleForOwner(owned, 'pi')).toBe( + prefix + title.replace('π', 'Pi') + ) + if (state === 'working') { + expect(detectAgentStatusFromTitle(clearPiStateWorkingMarker(owned) ?? '')).toBe('idle') + } + } + }) + it.each([ + 'omp-harness ready', + '/tmp/OMP : file', + 'lowercase omp : note', + 'Pi: legacy', + 'OMP ready' + ])('does not rewrite neutral or legacy title %s as a working marker', (title) => { + expect(clearPiStateWorkingMarker(title)).toBeNull() + expect(detectAgentStatusFromTitle(title)).not.toBe('working') + }) +}) diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts index 7b99235811d..0e9e5d2b196 100644 --- a/src/shared/pi-compatible-synthetic-title.ts +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -1,3 +1,5 @@ +import { getPiStateTitleStatus } from './pi-state-title-marker' + export type PiCompatibleSyntheticAgentLabel = 'Pi' | 'OMP' export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' @@ -71,6 +73,10 @@ export function isLegacyPiCompatibleTitle(title: string): boolean { export function getPiCompatibleTitleSeparatorStatus( title: string ): PiCompatibleSyntheticAgentStatus | null { + const nativeState = getPiStateTitleStatus(title) + if (nativeState) { + return nativeState + } // Why: a spinner anywhere means the agent is working, and that outranks the separator — // the frame is drawn over the idle separator position while a turn runs. if (containsBrailleSpinner(title)) { diff --git a/src/shared/pi-state-title-marker.ts b/src/shared/pi-state-title-marker.ts index 7f6ece71bd6..3d2ed19a759 100644 --- a/src/shared/pi-state-title-marker.ts +++ b/src/shared/pi-state-title-marker.ts @@ -27,15 +27,17 @@ function escapeForCharacterClass(marker: string): string { return marker.replace(/[\\\]^-]/g, '\\$&') } -// Why: `π` must sit at a token boundary so wrapper prefixes of any shape (`zsh | π : cwd`, +// Why: the brand must sit at a token boundary so wrapper prefixes (`zsh | OMP : cwd`, // `tmux: π : cwd`) still expose the marker, and whitespace must separate the marker so the // legacy no-space `π: cwd` disabled title keeps its historical idle classification. const PI_STATE_TITLE_RE = new RegExp( - `(?:^|[\\s|])π[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, + `(?:^|[\\s|])(π|Pi|OMP)[ \\t]+([${PI_STATE_MARKERS.map(escapeForCharacterClass).join('')}])(?=\\s|$)`, 'u' ) type PiStateTitleMatch = { + brand: string + brandIndex: number marker: PiStateMarker markerIndex: number } @@ -49,8 +51,14 @@ function matchPiStateTitle(title: string): PiStateTitleMatch | null { if (!match) { return null } + const marker = match[2] + if (marker !== ':' && marker !== '!' && marker !== '>') { + return null + } return { - marker: match[1] as PiStateMarker, + brand: match[1], + brandIndex: match.index + match[0].indexOf(match[1]), + marker, markerIndex: match.index + match[0].length - 1 } } @@ -73,3 +81,20 @@ export function clearPiStateWorkingMarker(title: string): string | null { } return `${title.slice(0, match.markerIndex)}${PI_IDLE_MARKER}${title.slice(match.markerIndex + 1)}` } + +/** The state marker owns identity too; its label may mention another agent. */ +export function getPiStateTitleBrand(title: string): 'Pi' | 'OMP' | null { + const match = matchPiStateTitle(title) + return match ? (match.brand === 'OMP' ? 'OMP' : 'Pi') : null +} + +/** Rebrand only the protocol prefix, preserving wrappers and the opaque session label. */ +export function rebrandPiStateTitle(title: string, brand: string): string | null { + const match = matchPiStateTitle(title) + if (!match) { + return null + } + return ( + title.slice(0, match.brandIndex) + brand + title.slice(match.brandIndex + match.brand.length) + ) +} diff --git a/tests/e2e/omp-title-marker.spec.ts b/tests/e2e/omp-title-marker.spec.ts new file mode 100644 index 00000000000..4f2db0ddbd9 --- /dev/null +++ b/tests/e2e/omp-title-marker.spec.ts @@ -0,0 +1,43 @@ +import { writeFile } from 'node:fs/promises' +import { buildShellCommandFromArgv } from '../../src/shared/tui-agent-startup-shell' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test('OMP spaced-colon title renders working and clears on idle', async ({ + orcaPage +}, testInfo) => { + test.skip( + process.platform === 'win32', + 'POSIX title replay; Windows formatter bytes have separate coverage' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const script = testInfo.outputPath('title-replay.cjs') + await writeFile( + script, + ` +process.stdout.write('\\x1b]0;OMP : Image review\\x07') +process.stdin.on('data', () => process.stdout.write('\\x1b]0;OMP > Image review\\x07')) +` + ) + await execInTerminal( + orcaPage, + ptyId, + buildShellCommandFromArgv([process.execPath, script], 'posix') + ) + const working = orcaPage.locator('[aria-label="Working"]') + await expect(working.first()).toBeVisible({ timeout: 15000 }) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-working.png') }) + await sendToTerminal(orcaPage, ptyId, '\r') + await expect(working).toHaveCount(0) + await orcaPage.screenshot({ path: testInfo.outputPath('omp-title-idle.png') }) +}) diff --git a/tests/tools/omp-native-title-capture.mjs b/tests/tools/omp-native-title-capture.mjs new file mode 100644 index 00000000000..9f4be2cc7c0 --- /dev/null +++ b/tests/tools/omp-native-title-capture.mjs @@ -0,0 +1,18 @@ +// Run under Bun through capture-agent-pty-transcript.mjs; sourceRoot is read-only. +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const sourceRoot = process.argv[2] +if (!sourceRoot) { + throw new Error('Expected path to the read-only oh-my-pi checkout') +} +const { buildTerminalTitleWithState } = await import( + pathToFileURL(resolve(sourceRoot, 'packages/coding-agent/src/utils/title-generator.ts')).href +) +for (const state of ['working', 'idle', 'attention']) { + for (const label of ['Run a long task', 'release | π : note | OMP ! action required ✦']) { + // Exercise upstream's explicit Windows argument, independently of the capture host OS. + const title = buildTerminalTitleWithState(label, state, 0, true, 'win32') + process.stdout.write(`\x1b]0;${title}\x07`) + } +} From fc4519cda4b5a91d3bd511daab7c292de2bdf8a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:22 -0700 Subject: [PATCH 06/13] fix(omp): preserve zsh startup with global aliases (#20621) Validated and independently reviewed OMP integration fix. --- .github/workflows/unit-tests.yml | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../daemon-bash-rcfile.txt | 4 +-- .../daemon-zsh-zshenv.txt | 4 +-- .../local-bash-rcfile.txt | 4 +-- .../local-zsh-zshenv.txt | 4 +-- .../relay-bash-rcfile.txt | 4 +-- .../relay-zsh-zshenv.txt | 4 +-- .../omp-shell-wrapper-alias-safety.test.ts | 25 +++++++++++++++++++ src/main/pty/omp-shell-wrapper.ts | 4 +-- 10 files changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b21feae3230..490eda88c33 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -56,6 +56,7 @@ jobs: --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/main/shell-startup-feature-channel.test.ts \ --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ed4e1b1f1c8..d18837a1573 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -15,6 +15,7 @@ const shellContractFiles = [ 'src/main/daemon/shell-ready.test.ts', 'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts', 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index b79ed543494..536831230b4 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -39,8 +39,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 3d3403ad099..f86bd569381 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index dc14486cdb7..a11d3e6183e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -42,8 +42,8 @@ __orca_restore_agent_teams_path # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 10e9e144fc0..35a262b5e02 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -77,8 +77,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index de9c8f95248..61bdd01dd50 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -31,8 +31,8 @@ fi # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 394bc4a6d10..ff90115d30d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -51,8 +51,8 @@ __orca_deferred_init() { # their normal argv shape. __orca_omp_should_skip_extension() { case "${1:-}" in - help|--help|-h|--version|-v) return 0 ;; - __complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; + '__complete'|'acp'|'agents'|'auth-broker'|'auth-gateway'|'bench'|'commit'|'completions'|'config'|'dry-balance'|'gallery'|'grep'|'grievances'|'install'|'join'|'models'|'plugin'|'read'|'say'|'search'|'setup'|'shell'|'ssh'|'stats'|'tiny-models'|'token'|'ttsr'|'update'|'usage'|'worktree'|'q'|'wt') return 0 ;; esac return 1 } diff --git a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts index 3863b3aacaf..d1d0e0aad83 100644 --- a/src/main/pty/omp-shell-wrapper-alias-safety.test.ts +++ b/src/main/pty/omp-shell-wrapper-alias-safety.test.ts @@ -68,3 +68,28 @@ describe.skipIf(process.platform === 'win32')('omp wrapper under a user alias na expectAliasedOmpNameSurvives('/bin/zsh', 'setopt aliases') }) }) + +describe.skipIf(process.platform === 'win32' || !zshAvailable)('OMP wrapper global aliases', () => { + it.each(['--help', '-v', 'models'])('parses with hostile global alias %s', (token) => { + const root = mkdtempSync(join(tmpdir(), 'orca-omp-global-alias-')) + roots.push(root) + const startup = join(root, 'startup.zsh') + writeFileSync( + startup, + [ + `alias -g -- ${token}='${token} 2>&1 | cat'`, + getPosixOmpShellWrapper(), + `if ! __orca_omp_should_skip_extension '${token}'; then exit 1; fi`, + 'printf "parsed\\n"', + `alias -g -- '${token}'` + ].join('\n') + ) + const result = spawnSync('/bin/zsh', ['-f', startup], { + encoding: 'utf8', + env: { ...process.env, HOME: root, ZDOTDIR: root } + }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('parsed') + expect(result.stdout).toContain('2>&1 | cat') + }) +}) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index f5bc25421bb..de1d9bed2af 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -40,13 +40,13 @@ const OMP_SUBCOMMANDS = [ ] as const export function getPosixOmpShellWrapper(): string { - const subcommands = OMP_SUBCOMMANDS.join('|') + const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join('|') return `# Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as \`omp config\` keep # their normal argv shape. __orca_omp_should_skip_extension() { case "\${1:-}" in - help|--help|-h|--version|-v) return 0 ;; + 'help'|'--help'|'-h'|'--version'|'-v') return 0 ;; ${subcommands}) return 0 ;; esac return 1 From ee1a0a4e2d40e10f8aa069962380e79e671de784 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:25 -0700 Subject: [PATCH 07/13] fix(git): avoid Windows tree kills after the command has exited (#20606) Validated and independently reviewed OMP integration fix. --- .../git-command-termination-runtime.yml | 22 ++++ .../spawned-command-tree-kill.test.ts | 122 ++++++++++++++++++ .../spawned-command-tree-kill.ts | 5 + 3 files changed, 149 insertions(+) create mode 100644 .github/workflows/git-command-termination-runtime.yml create mode 100644 src/main/git/command-runner/spawned-command-tree-kill.test.ts diff --git a/.github/workflows/git-command-termination-runtime.yml b/.github/workflows/git-command-termination-runtime.yml new file mode 100644 index 00000000000..1602bae956a --- /dev/null +++ b/.github/workflows/git-command-termination-runtime.yml @@ -0,0 +1,22 @@ +name: Git command termination runtime +on: + pull_request: + paths: + - 'src/main/git/command-runner/spawned-command-tree-kill*' + - '.github/workflows/git-command-termination-runtime.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + windows-exit: + runs-on: windows-latest + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Verify exited native child does not trigger taskkill + run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/spawned-command-tree-kill.test.ts diff --git a/src/main/git/command-runner/spawned-command-tree-kill.test.ts b/src/main/git/command-runner/spawned-command-tree-kill.test.ts new file mode 100644 index 00000000000..758fb296227 --- /dev/null +++ b/src/main/git/command-runner/spawned-command-tree-kill.test.ts @@ -0,0 +1,122 @@ +import { ChildProcess } from 'node:child_process' +import { once } from 'node:events' +import { spawnProcess } from '../../../shared/child-process/run-process' +import type * as NodeChildProcess from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, admitMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + admitMock: vi.fn(() => true) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('../../own-chromium-tree-kill-guard', () => ({ + admitSelfInitiatedTreeKill: admitMock +})) + +import { killSpawnedCommandTree } from './spawned-command-tree-kill' + +const originalPlatform = process.platform + +function childWithPid(pid: number): ChildProcess { + const child = new ChildProcess() + Object.defineProperty(child, 'pid', { value: pid }) + vi.spyOn(child, 'kill').mockReturnValue(true) + vi.spyOn(child, 'unref').mockImplementation(() => {}) + return child +} + +describe('Git command tree termination', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + spawnMock.mockReset() + admitMock.mockReset().mockReturnValue(true) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + vi.restoreAllMocks() + }) + + it.each([0, 128])( + 'never taskkills a child that exited with code %i before close', + async (code) => { + const child = childWithPid(1234) + Object.defineProperty(child, 'exitCode', { value: code }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledOnce() + } + ) + + it('never taskkills a child that exited by signal before close', async () => { + const child = childWithPid(1234) + Object.defineProperty(child, 'signalCode', { value: 'SIGTERM' }) + + await killSpawnedCommandTree(child) + + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + }) + + it('still waits for tree termination when the Windows root has not exited', async () => { + const child = childWithPid(1234) + const killer = childWithPid(5678) + spawnMock.mockReturnValue(killer) + let settled = false + const pending = killSpawnedCommandTree(child).then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + expect(spawnMock).toHaveBeenCalledWith('taskkill', ['/pid', '1234', '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.emit('close', 0) + await pending + expect(child.kill).not.toHaveBeenCalled() + }) + + it('preserves handle termination on POSIX', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + const child = childWithPid(1234) + + await killSpawnedCommandTree(child) + + expect(child.kill).toHaveBeenCalledOnce() + expect(spawnMock).not.toHaveBeenCalled() + }) + it.skipIf(originalPlatform !== 'win32').each([0, 128])( + 'does not taskkill an actual native Windows child after exit %i', + async (exitCode) => { + const original = await vi.importActual('node:child_process') + spawnMock.mockImplementation((program, args, options) => { + if (program !== process.execPath) { + throw new Error('Unexpected external process in native exit probe') + } + return original.spawn(program, args, options) + }) + const child = spawnProcess({ + program: process.execPath, + args: ['-e', `process.exit(${exitCode})`] + }) + const closed = once(child, 'close') + await once(child, 'exit') + expect(child.exitCode).toBe(exitCode) + expect(child.pid).toBeGreaterThan(0) + spawnMock.mockClear() + await killSpawnedCommandTree(child) + expect(spawnMock).not.toHaveBeenCalled() + expect(admitMock).not.toHaveBeenCalled() + await closed + } + ) +}) diff --git a/src/main/git/command-runner/spawned-command-tree-kill.ts b/src/main/git/command-runner/spawned-command-tree-kill.ts index 324e04f1db3..035764d5249 100644 --- a/src/main/git/command-runner/spawned-command-tree-kill.ts +++ b/src/main/git/command-runner/spawned-command-tree-kill.ts @@ -9,6 +9,11 @@ export function killSpawnedCommandTree(child: ChildProcess): Promise { child.kill() return Promise.resolve() } + // Windows may reuse the pid after exit while inherited pipes still delay close. + if ((child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null) { + child.kill() + return Promise.resolve() + } if ( !admitSelfInitiatedTreeKill({ pid, site: 'git-command-tree-kill', scope: 'win-taskkill-tree' }) ) { From 8d93505958e00492a82de4f464ce4adbcd081776 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:57:59 -0700 Subject: [PATCH 08/13] fix(terminal): retain renames before renderer pane hydration (#20619) * fix(terminal): retain renames before renderer pane hydration * test(terminal): keep late renames from recreating closed tabs --- ...runtime-resolve-worktree-removal-target.ts | 8 +- ...-runtime-terminal-rename-retention.test.ts | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/orca-runtime-terminal-rename-retention.test.ts diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 0d934548332..44f4524c782 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -134,7 +134,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return { handle, tabId: leaf.tabId, title } } } - return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } + const tabId = pty.pty.tabId ?? pty.record.tabId + // A notifier can exist before its pane graph; retain the rename on the known tab. + if (this.notifier?.renameTerminal && tabId) { + this.persistHeadlessTerminalTitle(pty.pty.worktreeId, tabId, title) + this.notifier.renameTerminal(tabId, title) + } + return { handle, tabId, title } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) diff --git a/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts new file mode 100644 index 00000000000..87aabaf4c54 --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-rename-retention.test.ts @@ -0,0 +1,92 @@ +import './orca-runtime-test-lifecycle.spec' +import type { RuntimeStore } from './runtime-store-contract' +import { describe, expect, it, vi } from 'vitest' +import { createMobileCreateTestNotifier } from './orca-runtime-test-scenario-builders.spec' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal +} from './orca-runtime-test-fixtures.spec' + +describe('terminal rename before renderer graph hydration', () => { + it.each(['Media Engine Orch', null])( + 'persists and forwards title %s across PTY replacement', + async (title) => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + session.tabsByWorktree[TEST_WORKTREE_ID][0].customTitle = 'Previous name' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures. + const checkedStore = runtimeStore as RuntimeStore + const runtime = new OrcaRuntimeService(checkedStore) + const renameTerminal = vi.fn() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-initial-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal, + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + await runtime.renameTerminal(created.handle, title) + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + expect(renameTerminal).toHaveBeenCalledWith('host-tab', title) + runtime.onPtyExit('omp-initial-pty', 0) + const restored = new OrcaRuntimeService(checkedStore) + restored.setPtyController({ + spawn: vi.fn(async () => ({ id: 'omp-replacement-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await restored.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID][0].customTitle).toBe(title) + } + ) + it('does not recreate a closed persisted tab from a surviving PTY record', async () => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Shared fixture implements RuntimeStore; its legacy Mock typing loses callable signatures. + const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore) + const notifier = createMobileCreateTestNotifier(vi.fn()) + runtime.setNotifier(notifier) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'surviving-pty' })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + setSession({ ...getSession(), tabsByWorktree: { [TEST_WORKTREE_ID]: [] } }) + runtimeStore.setWorkspaceSession.mockClear() + + await runtime.renameTerminal(created.handle, 'Late rename') + + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(runtimeStore.setWorkspaceSession).not.toHaveBeenCalled() + }) +}) From f21f81dcfc4ddc0e58b20e653768205685b682ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:03:11 -0700 Subject: [PATCH 09/13] fix(agents): find OMP by its full project name (#20647) * fix(agents): find OMP by its full project name * test(agents): make picker baseline proof omit OMP aliases * style(test): brace picker baseline condition --- src/renderer/src/lib/agent-catalog.tsx | 2 + .../src/lib/agent-picker-search.test.ts | 9 ++ src/renderer/src/lib/agent-picker-search.ts | 3 +- .../omp-picker-search-rendered/README.md | 17 ++++ .../omp-picker-search-rendered/fixture.css | 4 + .../omp-picker-search-rendered/fixture.tsx | 29 ++++++ .../omp-picker-search-rendered/index.html | 10 ++ .../tools/omp-picker-search-rendered/run.mjs | 93 +++++++++++++++++++ 8 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/tools/omp-picker-search-rendered/README.md create mode 100644 tests/tools/omp-picker-search-rendered/fixture.css create mode 100644 tests/tools/omp-picker-search-rendered/fixture.tsx create mode 100644 tests/tools/omp-picker-search-rendered/index.html create mode 100644 tests/tools/omp-picker-search-rendered/run.mjs diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index b6075183947..cf46fd9aae7 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -21,6 +21,7 @@ export type AgentCatalogEntry = { label: string /** Default CLI binary name used for PATH detection. */ cmd: string + searchAliases?: readonly string[] /** Direct or bundled image URL for agents whose project identity is not represented by a favicon service. */ iconUrl?: string /** Domain for Google's favicon service — used for agents without an SVG icon. */ @@ -123,6 +124,7 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => id: 'omp', label: translate('auto.lib.agent.catalog.09973b4d84', 'OMP'), cmd: 'omp', + searchAliases: ['oh-my-pi', 'oh my pi'], // Why: no faviconDomain — omp renders the hand-authored OmpIcon glyph, so a // favicon fallback would never be reached. homepageUrl: 'https://omp.sh' diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index fe26fd53dc3..12fe441103f 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -25,6 +25,15 @@ afterEach(() => { }) describe('agent picker search', () => { + it.each(['oh-my-pi', 'oh my pi', 'OH-MY-PI'])('finds OMP by its project name: %s', (query) => { + expect(searchAgentPickerEntries(AGENT_CATALOG, query).map((agent) => agent.id)).toEqual(['omp']) + }) + + it('does not offer unavailable OMP through a search alias', () => { + const available = AGENT_CATALOG.filter((agent) => agent.id !== 'omp') + expect(searchAgentPickerEntries(available, 'oh-my-pi')).toEqual([]) + }) + it('keeps catalog order for an empty query', () => { expect(searchAgentPickerEntries(agents, '').map((agent) => agent.id)).toEqual( agents.map((agent) => agent.id) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index 6fb980bc8ea..38c368f3d73 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -87,7 +87,8 @@ function scoreAgent(agent: AgentCatalogEntry, query: string): number { return Math.min( scoreCandidate(query, agent.label, 0), scoreCandidate(query, agent.id, 600), - scoreCandidate(query, agent.cmd, 650) + scoreCandidate(query, agent.cmd, 650), + ...(agent.searchAliases ?? []).map((alias) => scoreCandidate(query, alias, 650)) ) } diff --git a/tests/tools/omp-picker-search-rendered/README.md b/tests/tools/omp-picker-search-rendered/README.md new file mode 100644 index 00000000000..653a7bd1c33 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/README.md @@ -0,0 +1,17 @@ +# OMP picker project-name search (#14319) + +Run `ORCA_BACKGROUND_LAUNCH=1 node tests/tools/omp-picker-search-rendered/run.mjs`. +Uses production AgentCombobox, the production catalog and canonical CSS in a hidden +Electron renderer. Rebuilds the existing background-launch harness before execution; +all windows must remain invisible and unfocused. No dependency install is needed. + +The probe types `oh-my-pi`, captures the resulting OMP row over CDP, selects it, +asserts the callback receives `omp`, and checks `oh my pi` too. Reports/screenshots +are local under `.bench-fixtures/omp-picker-search-*`. Before proof uses the baseline +catalog without search aliases and `ORCA_OMP_PICKER_BASELINE=1`, expecting no match. + +Available agents are supplied by the fixture. This does not exercise local/SSH/WSL +PATH detection, disabled-agent settings, terminal launch, or a full workspace form. +Search only ranks entries supplied by each caller; aliases cannot introduce an +agent absent from that list. The broader missing-agent explanation in #14319 is +separate from this reproduced project-name search defect. diff --git a/tests/tools/omp-picker-search-rendered/fixture.css b/tests/tools/omp-picker-search-rendered/fixture.css new file mode 100644 index 00000000000..9c08bc0261c --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.css @@ -0,0 +1,4 @@ +@import '../../../src/renderer/src/assets/main.css'; +@source './fixture.tsx'; +@source '../../../src/renderer/src/components/agent/AgentCombobox.tsx'; +@source '../../../src/renderer/src/components/ui'; diff --git a/tests/tools/omp-picker-search-rendered/fixture.tsx b/tests/tools/omp-picker-search-rendered/fixture.tsx new file mode 100644 index 00000000000..b318cdee827 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/fixture.tsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { createRoot } from 'react-dom/client' +import AgentCombobox from '../../../src/renderer/src/components/agent/AgentCombobox' +import { getAgentCatalog } from '../../../src/renderer/src/lib/agent-catalog' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import './fixture.css' +const baseline = new URLSearchParams(window.location.search).get('baseline') === '1' +const agents = getAgentCatalog().map((agent) => + baseline && agent.id === 'omp' ? { ...agent, searchAliases: [] } : agent +) +function App() { + const [selected, setSelected] = useState(null) + return ( +
+

Agent picker

+ +

Selected agent: {selected ?? 'none'}

+
+ ) +} +const root = document.getElementById('root') +if (root) { + createRoot(root).render() +} diff --git a/tests/tools/omp-picker-search-rendered/index.html b/tests/tools/omp-picker-search-rendered/index.html new file mode 100644 index 00000000000..44793ff9830 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/tests/tools/omp-picker-search-rendered/run.mjs b/tests/tools/omp-picker-search-rendered/run.mjs new file mode 100644 index 00000000000..14bee4a6752 --- /dev/null +++ b/tests/tools/omp-picker-search-rendered/run.mjs @@ -0,0 +1,93 @@ +import { _electron as electron, expect } from '@stablyai/playwright-test' +import { build as buildMain } from 'esbuild' +import { build as buildRenderer } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Requires ORCA_BACKGROUND_LAUNCH=1') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const parent = path.join(root, '.bench-fixtures') +mkdirSync(parent, { recursive: true }) +const output = mkdtempSync(path.join(parent, 'omp-picker-search-')) +const main = path.join(output, 'main.cjs') +await buildMain({ + entryPoints: [path.join(root, 'tests/tools/benchmarks/spinner-rendering/main.ts')], + outfile: main, + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron'] +}) +await buildRenderer({ + configFile: false, + root: import.meta.dirname, + base: './', + logLevel: 'silent', + plugins: [react(), tailwindcss()], + resolve: { alias: { '@': path.join(root, 'src/renderer/src') } }, + build: { outDir: path.join(output, 'renderer'), emptyOutDir: true } +}) +const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env +const app = await electron.launch({ args: [main], env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' } }) +const report = { + scope: + 'Production AgentCombobox and agent catalog in hidden Electron; supplied available agents, no PATH detection or terminal launch.' +} +try { + const page = await app.firstWindow() + const errors = [] + page.on('pageerror', (error) => { + errors.push(error.message) + console.error(error) + }) + const baseline = process.env.ORCA_OMP_PICKER_BASELINE === '1' + const fixtureUrl = pathToFileURL(path.join(output, 'renderer/index.html')) + if (baseline) { + fixtureUrl.searchParams.set('baseline', '1') + } + await page.goto(fixtureUrl.href) + await page.locator('button[role=combobox]').click() + const search = page.getByPlaceholder('Search agents...') + await search.fill('oh-my-pi') + await expect( + baseline + ? page.getByText('No agents match your search.') + : page.getByRole('option', { name: 'OMP', exact: true }) + ).toBeVisible() + await page.evaluate(async () => { + await Promise.all( + document.getAnimations().map((animation) => animation.finished.catch(() => {})) + ) + }) + const cdp = await page.context().newCDPSession(page) + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }) + writeFileSync( + path.join(output, baseline ? 'before.png' : 'after.png'), + Buffer.from(data, 'base64') + ) + if (!baseline) { + await page.getByRole('option', { name: 'OMP', exact: true }).click() + await expect(page.getByText('Selected agent: omp', { exact: true })).toBeVisible() + await expect(search).toBeHidden() + await page.locator('button[role=combobox]').click() + await search.fill('oh my pi') + await expect(page.getByRole('option', { name: 'OMP', exact: true })).toBeVisible() + } + report.baseline = baseline + expect(errors).toEqual([]) + report.windows = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + expect(report.windows.every((window) => !window.visible && !window.focused)).toBe(true) +} finally { + writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`) + console.log(`OMP picker search evidence: ${output}`) + await app.close() +} From 68f0b2e8355d3376e4ace32b1ae0cb81f068bdab Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:08:31 +0200 Subject: [PATCH 10/13] feat(runtime): stream file uploads instead of buffering whole files (#16106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(runtime): stream file uploads instead of buffering whole files Staging read each dropped file whole with readFile(), base64-encoded it (a 4/3 expansion), and passed the string through IPC to the renderer, which re-chunked it. Peak memory was ~2.3x the file size before a byte moved, so a 25 MB per-file cap existed to protect the heap. Staging now records identity only. The byte pump moves into main, where the file handle and the runtime socket both live: 384 KiB slices (512 KiB once base64-encoded, matching the chunk size the renderer used) appended through the existing files.writeBase64Chunk RPC. Peak memory is one slice regardless of file size, so the ceilings become user-safety limits on an unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors name both the size and the limit. Because staging and streaming are separate calls, the staged entry carries size, inode, device and mtime, and the streamer re-checks all four against the pre-open lstat and against the handle it actually reads. A source replaced or rewritten at the same size between the two calls is refused rather than uploaded under the original name. The post-read check compares mtime as well as size, so an in-place rewrite mid-transfer aborts before commitUpload renames anything into place. O_NOFOLLOW, realpath containment and stat identity are preserved, and the pairing revision plus the runtime id ride every chunk, so a re-pair or a replacement runtime aborts instead of appending the rest of the file to a different host. No wire change: files.writeBase64Chunk and its params are untouched, so old and new hosts behave identically. The SSH import path is separate and unchanged. The web client has no local filesystem to stream from and says so instead of failing obscurely. * fix(runtime): close the empty-upload and per-drop budget holes Two gaps the first pass left open. A zero-byte source returned before the post-transfer identity check, so a file that gained content during the empty write's round trip committed as an empty file at the user's chosen name. The empty chunk now falls through to the same final check the slice loop uses. Each staged source also started its own byte counter, so the 8 GB ceiling capped one source rather than the drop: five 2 GB files staged cleanly at 10 GB total. The IPC handler now carries one budget across sourcePaths and adds only what each source actually staged. The per-file ceiling is still re-enforced where the bytes move; the drop total holds at staging because identity enforcement means each file streams exactly the bytes measured. * docs(runtime): name the invariants the upload helpers carry * fix(runtime): name the source in errors and stop uploads with their window Three problems an independent review turned up. A dropped file's relative path is '', so the over-limit error read "'' is 3 GB, over the 2 GB per-file remote import limit" — the message this change exists to fix, naming nothing. Errors now fall back to the file's own name; the staged entry keeps '' so the destination path is unaffected. The streamer had the same shape, falling back to the hidden .orca-upload- temp destination, a path the user never chose. The byte loop used to live in the renderer and died with it. Moving it into main meant closing or reloading the window left the rest of a multi-GB transfer running, with the renderer's temp cleanup never reaching its finally. An AbortSignal now rides the caller's lifetime and every chunk, is re-checked per slice, and main sweeps the abandoned temp path itself when the renderer is no longer there to do it. Upload failures also reached the import result wrapped in Electron's "Error invoking remote method '...'" prefix, because the throw crossed IPC instead of happening in-renderer; extractIpcErrorMessage unwraps it. An existing staging test asserted the empty-name message, so it encoded the bug rather than catching it; it now asserts the file name. * test(runtime): cover the containment check and the per-chunk host guards The "escapes the dropped root" test only reached the lstat symlink guard, so assertEntryInsideRoot had no coverage at all. The shape that actually needs it is a regular file under a symlinked intermediate directory: lstat sees a plain file, and realpath containment is the only thing that refuses it. Disabling the guard now fails this test and nothing else. Nothing asserted that the SSH target, connection generation and execution host reach the writeBase64Chunk params either — the renderer tests stop at the IPC boundary, so the streamer's half of that contract was untested. * fix(runtime): survive a straggling append when sweeping an aborted upload Aborting rejects the in-flight chunk locally, but the host may still apply that append, and appends open with flag 'a' — which recreates the file the sweep just deleted. The delete and the straggler also race: they are separate calls on a queue that is not ordered between them. Slices are strictly sequential, so at most one append can be outstanding. A second pass after it has had time to land is therefore sufficient, not merely a heuristic. The sweep moves out of filesystem-mutations.ts into its own module so the behaviour is testable directly. Found by an independent review pass, which also pointed out that the "escapes the dropped root" test only reached the lstat symlink guard. * fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk did-start-navigation fires before will-navigate blocks an external link or a stray file drop, and the renderer survives those (verified against Electron 43 with a hidden window). Aborting there killed a healthy upload with a misleading 'window went away' error. did-navigate fires only once a new document has replaced the caller. The renderer's per-chunk calls used to go through the IPC handler that refuses a manually disconnected environment; the loop in main made no such check, so a disconnect mid-upload kept pushing the rest of the file. The handler now resolves the selector to an environment id and the streamer checks it per slice. Adds slice-boundary coverage against the real chunk schema and host write flags, staging-to-stream on a real filesystem, and handler-level lifetime tests. --------- Co-authored-by: Neil --- .../ipc/filesystem-import-result-types.ts | 31 +- src/main/ipc/filesystem-import.test.ts | 53 +- ...ilesystem-mutations-runtime-upload.test.ts | 176 ++++++ src/main/ipc/filesystem-mutations.ts | 55 +- .../filesystem-runtime-upload-staging.test.ts | 166 +++++ .../ipc/filesystem-runtime-upload-staging.ts | 97 +-- src/main/ipc/renderer-lifetime-abort.test.ts | 91 +++ src/main/ipc/renderer-lifetime-abort.ts | 45 ++ ...ntime-environment-connectivity-handlers.ts | 5 +- .../runtime-environment-manual-disconnect.ts | 2 + src/main/ipc/runtime-import-limits.test.ts | 33 + src/main/ipc/runtime-import-limits.ts | 18 + .../ipc/runtime-upload-file-stream.test.ts | 438 +++++++++++++ src/main/ipc/runtime-upload-file-stream.ts | 213 +++++++ .../runtime-upload-slice-boundaries.test.ts | 383 ++++++++++++ .../ipc/runtime-upload-temp-sweep.test.ts | 94 +++ src/main/ipc/runtime-upload-temp-sweep.ts | 49 ++ src/preload/api/filesystem-api.ts | 34 +- src/preload/api/fs-bridge.ts | 33 +- ...untime-file-client-external-import.test.ts | 584 ++++-------------- .../runtime-file-client-test-harness.ts | 6 +- .../src/runtime/runtime-file-import-client.ts | 32 +- ...ntime-file-import-pairing-revision.test.ts | 97 +-- .../src/runtime/runtime-file-upload-client.ts | 89 +-- .../src/web/preload-api/web-filesystem-api.ts | 5 + src/shared/runtime-upload-staging-contract.ts | 50 ++ 26 files changed, 2190 insertions(+), 689 deletions(-) create mode 100644 src/main/ipc/filesystem-mutations-runtime-upload.test.ts create mode 100644 src/main/ipc/filesystem-runtime-upload-staging.test.ts create mode 100644 src/main/ipc/renderer-lifetime-abort.test.ts create mode 100644 src/main/ipc/renderer-lifetime-abort.ts create mode 100644 src/main/ipc/runtime-import-limits.test.ts create mode 100644 src/main/ipc/runtime-import-limits.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.test.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.ts create mode 100644 src/main/ipc/runtime-upload-slice-boundaries.test.ts create mode 100644 src/main/ipc/runtime-upload-temp-sweep.test.ts create mode 100644 src/main/ipc/runtime-upload-temp-sweep.ts create mode 100644 src/shared/runtime-upload-staging-contract.ts diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/main/ipc/filesystem-import-result-types.ts index d1d9f446836..1d3e1a3fcad 100644 --- a/src/main/ipc/filesystem-import-result-types.ts +++ b/src/main/ipc/filesystem-import-result-types.ts @@ -1,3 +1,8 @@ +import type { + StagedRuntimeUploadEntry, + StagedRuntimeUploadSource +} from '../../shared/runtime-upload-staging-contract' + export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' export type ResolveDroppedPathsResult = { @@ -27,25 +32,7 @@ export type ImportItemResult = reason: string } -export type StagedExternalImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedExternalImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: ImportSkipReason - } - | { - sourcePath: string - status: 'failed' - reason: string - } - -export type StagedExternalImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } +// Why: staging crosses IPC to the renderer and back into the streamer, so the +// shape lives in shared and every layer names the same type. +export type StagedExternalImportSource = StagedRuntimeUploadSource +export type StagedExternalImportEntry = StagedRuntimeUploadEntry diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index bb143987c96..7703cd8a8b1 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -73,6 +73,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -94,6 +95,7 @@ describe('fs:importExternalPaths', () => { size: entry.isDir ? 0 : 12, ino: entry.isDir ? 2 : 3, dev: 1, + mtimeMs: 1700000000000, isFile: () => !entry.isDir, isDirectory: () => entry.isDir, isSymbolicLink: () => false @@ -142,6 +144,7 @@ describe('fs:importExternalPaths', () => { size: content.byteLength, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([content]), @@ -216,6 +219,7 @@ describe('fs:importExternalPaths', () => { size: 12, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), createReadStream: () => Readable.from([Buffer.from('file-content')]), @@ -484,6 +488,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -498,6 +503,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -514,11 +520,21 @@ describe('fs:importExternalPaths', () => { status: 'staged', name: 'logo.png', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength: 4, + inode: 1, + deviceId: 1, + modifiedAtMs: 1700000000000 + } + ] } ]) expect(copyFileMock).not.toHaveBeenCalled() - expect(readFileHandleMock).toHaveBeenCalled() + // Why: bodies stream at upload time, so staging must never read the file. + expect(readFileHandleMock).not.toHaveBeenCalled() expect(closeMock).toHaveBeenCalled() }) @@ -533,6 +549,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -543,6 +560,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -578,6 +596,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: vi.fn().mockResolvedValue(Buffer.from('icon')), @@ -597,7 +616,14 @@ describe('fs:importExternalPaths', () => { entries: [ { relativePath: '', kind: 'directory' }, { relativePath: '..assets', kind: 'directory' }, - { relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' } + { + relativePath: '..assets/icon.txt', + kind: 'file', + byteLength: 4, + inode: 2, + deviceId: 1, + modifiedAtMs: 1700000000000 + } ] } ]) @@ -612,6 +638,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -637,14 +664,15 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + it('checks runtime upload directory byte budget before opening a file that exceeds the total cap', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => path.join(resolvedPath, name) ) const mib = 1024 * 1024 - const regularSize = 25 * mib + // Four files exactly fill the 8 GB total ceiling; the fifth pushes past it. + const regularSize = 2 * 1024 * mib const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) @@ -654,6 +682,7 @@ describe('fs:importExternalPaths', () => { size: 0, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false @@ -666,6 +695,7 @@ describe('fs:importExternalPaths', () => { size, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -689,6 +719,7 @@ describe('fs:importExternalPaths', () => { size: regularSize, ino: fileIndex + 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileMock, @@ -702,11 +733,9 @@ describe('fs:importExternalPaths', () => { sourcePaths: [sourcePath] })) as { sources: { status: string; reason?: string }[] } - expect(result.sources[0]).toMatchObject({ - status: 'failed', - reason: 'Remote import is too large' - }) - expect(readFileMock).toHaveBeenCalledTimes(4) + expect(result.sources[0]).toMatchObject({ status: 'failed' }) + expect(result.sources[0]?.reason).toContain('total remote import limit') + expect(readFileMock).not.toHaveBeenCalled() expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) }) @@ -719,6 +748,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 1, dev: 1, + mtimeMs: 1700000000000, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false @@ -732,6 +762,7 @@ describe('fs:importExternalPaths', () => { size: 4, ino: 2, dev: 1, + mtimeMs: 1700000000000, isFile: () => true }), readFile: readFileHandleMock, @@ -744,7 +775,7 @@ describe('fs:importExternalPaths', () => { expect(result.sources[0]).toMatchObject({ status: 'failed', - reason: "File changed during upload staging: ''" + reason: "File changed during upload staging: 'logo.png'" }) expect(readFileHandleMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts new file mode 100644 index 00000000000..fa7edc1d5b7 --- /dev/null +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -0,0 +1,176 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map Promise>() +const { handleMock, streamMock, sweepMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + streamMock: vi.fn(), + sweepMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock }, + app: { getPath: () => '/user-data' } +})) +vi.mock('./runtime-upload-file-stream', () => ({ + streamExternalFileToRuntime: streamMock +})) +vi.mock('./runtime-upload-temp-sweep', () => ({ + sweepAbandonedRuntimeUploadTempPath: sweepMock +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ + id: selector === 'env-alias' ? 'env-1' : selector + }) +})) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' +import { RENDERER_GONE_MESSAGE } from './renderer-lifetime-abort' + +const request = { + environmentId: 'env-1', + sourceRootPath: '/drop/file.bin', + entryRelativePath: '', + expected: { byteLength: 1, inode: 1, deviceId: 1, modifiedAtMs: 1 }, + worktree: 'wt-1', + relativePath: '.file.bin.orca-upload-x', + expectedEnvironmentPairingRevision: 3, + expectedEnvironmentRuntimeId: 'rt-1' +} + +function fakeSender(): EventEmitter { + return new EventEmitter() +} + +function listenerCount(sender: EventEmitter): number { + return ['destroyed', 'render-process-gone', 'did-navigate'].reduce( + (total, name) => total + sender.listenerCount(name), + 0 + ) +} + +beforeEach(() => { + handlers.clear() + handleMock.mockReset() + streamMock.mockReset() + sweepMock.mockReset() + sweepMock.mockResolvedValue(undefined) + handleMock.mockImplementation((channel: string, handler: never) => { + handlers.set(channel, handler) + }) + registerFilesystemMutationHandlers( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the upload handler under test never reads the store; registration only needs a Store-shaped value. + { getRepos: () => [], getSettings: () => ({ workspaceDir: '/workspace' }) } as never + ) +}) + +function invoke(sender: EventEmitter): Promise { + return handlers.get('fs:uploadExternalFileToRuntime')!({ sender }, request) +} + +describe('fs:uploadExternalFileToRuntime', () => { + it('streams with the user data path and a live signal, and leaves no listeners behind', async () => { + const sender = fakeSender() + streamMock.mockImplementation(async (args: { userDataPath: string; signal: AbortSignal }) => { + expect(args.userDataPath).toBe('/user-data') + expect(args.signal.aborted).toBe(false) + expect(listenerCount(sender)).toBe(3) + return { byteLength: 42 } + }) + + await expect(invoke(sender)).resolves.toEqual({ byteLength: 42 }) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining(request)) + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('resolves the selector to the environment id before streaming and sweeping', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect( + handlers.get('fs:uploadExternalFileToRuntime')!( + { sender }, + { ...request, environmentId: 'env-alias' } + ) + ).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(streamMock).toHaveBeenCalledWith(expect.objectContaining({ environmentId: 'env-1' })) + expect(sweepMock).toHaveBeenCalledWith('/user-data', { ...request, environmentId: 'env-1' }) + }) + + it('aborts, sweeps the temp path, and rethrows when the renderer is destroyed mid-stream', async () => { + const sender = fakeSender() + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('destroyed') + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + + expect(sweepMock).toHaveBeenCalledTimes(1) + expect(sweepMock).toHaveBeenCalledWith('/user-data', request) + expect(listenerCount(sender)).toBe(0) + }) + + it('aborts once a reload commits, not on a blocked navigation or an in-app route change', async () => { + const sender = fakeSender() + let observed: AbortSignal | undefined + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((resolve, reject) => { + observed = signal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: true }) + sender.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + queueMicrotask(() => { + expect(signal.aborted).toBe(false) + sender.emit('did-navigate', 'file:///app/index.html', 200, 'OK') + resolve({ byteLength: 0 }) + }) + }) + ) + + await expect(invoke(sender)).rejects.toThrow(RENDERER_GONE_MESSAGE) + expect(observed?.aborted).toBe(true) + expect(sweepMock).toHaveBeenCalledTimes(1) + }) + + it('does not sweep when the stream fails while the renderer is still alive', async () => { + const sender = fakeSender() + streamMock.mockRejectedValue(new Error("File changed since it was staged: 'file.bin'")) + + await expect(invoke(sender)).rejects.toThrow("File changed since it was staged: 'file.bin'") + + expect(sweepMock).not.toHaveBeenCalled() + expect(listenerCount(sender)).toBe(0) + }) + + it('still rethrows the stream error if the sweep itself throws', async () => { + const sender = fakeSender() + sweepMock.mockRejectedValue(new Error('sweep exploded')) + streamMock.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + sender.emit('render-process-gone') + }) + ) + + // Why: the sweep contract is "never rejects"; if it ever did, this documents + // that the handler would surface the sweep error instead of the upload's. + await expect(invoke(sender)).rejects.toThrow('sweep exploded') + expect(listenerCount(sender)).toBe(0) + }) +}) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 57ac0c5e197..7cce83aa3e5 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' @@ -18,7 +18,15 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' -import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { + stagedRuntimeUploadByteLength, + stageOneSourceForRuntimeUpload +} from './filesystem-runtime-upload-staging' +import { streamExternalFileToRuntime } from './runtime-upload-file-stream' +import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { resolveEnvironment } from '../../shared/runtime-environment-store' /** * IPC handlers for file/folder creation and renaming. @@ -196,13 +204,54 @@ export function registerFilesystemMutationHandlers(store: Store): void { args: { sourcePaths: string[] } ): Promise<{ sources: StagedExternalImportSource[] }> => { const sources: StagedExternalImportSource[] = [] + // Why: one budget for the whole drop — per-source counters would let five + // 2 GB files through a ceiling meant to cap the drop. + let totalBytes = 0 for (const sourcePath of args.sourcePaths) { - sources.push(await stageOneSourceForRuntimeUpload(sourcePath)) + const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes) + totalBytes += stagedRuntimeUploadByteLength(source) + sources.push(source) } return { sources } } ) + // Why: the file handle and the runtime socket both live in main, so the byte + // pump runs here. The renderer keeps deconflict/commit/rollback orchestration + // and never sees file contents. + ipcMain.handle( + 'fs:uploadExternalFileToRuntime', + async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => { + const userDataPath = app.getPath('userData') + // Why: the streamer's manual-disconnect check keys on the environment id, + // and the renderer may pass any selector the store resolves. + const request = { + ...args, + environmentId: resolveEnvironment(userDataPath, args.environmentId).id + } + // Why: the renderer's own loop died with its window. Now that the bytes + // move in main, a reload or close has to stop the transfer explicitly, + // or a multi-GB upload outlives the window that asked for it. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await streamExternalFileToRuntime({ + ...request, + userDataPath, + signal: lifetime.signal + }) + } catch (error) { + if (lifetime.signal.aborted) { + // Why: the renderer owns temp cleanup, and it is gone — so the + // abandoned temp path is only collectable from here. + await sweepAbandonedRuntimeUploadTempPath(userDataPath, request) + } + throw error + } finally { + lifetime.dispose() + } + } + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts new file mode 100644 index 00000000000..3fe70b4e1f2 --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -0,0 +1,166 @@ +import { lstat, mkdtemp, mkdir, rm, symlink, 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 RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a +// literal over-limit fixture would allocate that much on Windows CI. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024, + REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 * 1024 +})) + +const { stagedRuntimeUploadByteLength, stageOneSourceForRuntimeUpload } = + await import('./filesystem-runtime-upload-staging') + +let workDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-staging-')) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('stageOneSourceForRuntimeUpload', () => { + it('records size instead of file contents so staging never holds the body', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + kind: 'file', + name: 'note.txt', + entries: [{ relativePath: '', kind: 'file', byteLength: 11 }] + }) + expect(JSON.stringify(staged)).not.toContain('contentBase64') + }) + + it('records the identity the uploader re-checks, not just the size', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + const stat = await lstat(filePath) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + entries: [ + { + byteLength: 11, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } + ] + }) + }) + + it('stages a file with no cap error, where the old buffering path refused', async () => { + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ + status: 'staged', + entries: [{ kind: 'file', byteLength: 3 * 1024 }] + }) + }) + + it('names the file, the actual size and the limit when a file is over the ceiling', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + // Why: a dropped file's relative path is '', so this is the regression that + // would otherwise report "'' is 6 KB, over the 4 KB ... limit". + expect(staged.status === 'failed' && staged.reason).toBe( + "'clip.mp4' is 6 KB, over the 4 KB per-file remote import limit" + ) + }) + + it('names the offending entry by its path inside a dropped directory', async () => { + const rootPath = join(workDir, 'media') + await mkdir(join(rootPath, 'clips'), { recursive: true }) + await writeFile(join(rootPath, 'clips', 'big.mp4'), Buffer.alloc(6 * 1024)) + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status === 'failed' && staged.reason).toContain("'clips/big.mp4'") + }) + + it('counts earlier sources in the drop against the total ceiling', async () => { + const filePath = join(workDir, 'second.bin') + await writeFile(filePath, Buffer.alloc(3 * 1024)) + + // Alone it fits; after 14 KB of earlier sources the 16 KB drop ceiling is gone. + await expect(stageOneSourceForRuntimeUpload(filePath, 0)).resolves.toMatchObject({ + status: 'staged' + }) + const overBudget = await stageOneSourceForRuntimeUpload(filePath, 14 * 1024) + expect(overBudget).toMatchObject({ status: 'failed' }) + expect(overBudget.status === 'failed' && overBudget.reason).toContain( + 'total remote import limit' + ) + }) + + it('reports the bytes a source contributes to the drop budget', async () => { + const rootPath = join(workDir, 'tree') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(stagedRuntimeUploadByteLength(staged)).toBe(5) + expect( + stagedRuntimeUploadByteLength({ + sourcePath: '/missing', + status: 'skipped', + reason: 'missing' + }) + ).toBe(0) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { + const targetPath = join(workDir, 'target.txt') + await writeFile(targetPath, 'data') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(stageOneSourceForRuntimeUpload(linkPath)).resolves.toMatchObject({ + status: 'skipped', + reason: 'symlink' + }) + }) + + it('stages directory trees as metadata for every entry', async () => { + const rootPath = join(workDir, 'assets') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status).toBe('staged') + const entries = staged.status === 'staged' ? staged.entries : [] + expect(entries).toEqual( + expect.arrayContaining([ + { relativePath: '', kind: 'directory' }, + expect.objectContaining({ relativePath: 'a.txt', kind: 'file', byteLength: 2 }), + { relativePath: 'nested', kind: 'directory' }, + expect.objectContaining({ relativePath: 'nested/b.txt', kind: 'file', byteLength: 3 }) + ]) + ) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 5af76029b98..86b5f884820 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -1,3 +1,8 @@ +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -8,18 +13,31 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' -const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 - class RuntimeUploadSymlinkError extends Error {} +/** Bytes this source contributes to the drop budget; 0 unless it staged. */ +export function stagedRuntimeUploadByteLength(source: StagedExternalImportSource): number { + if (source.status !== 'staged') { + return 0 + } + return source.entries.reduce( + (total, entry) => (entry.kind === 'file' ? total + entry.byteLength : total), + 0 + ) +} + +/** + * @param totalBytesBefore Bytes already staged by earlier sources in the same drop, + * so the total ceiling covers the whole drop rather than each source alone. + */ export async function stageOneSourceForRuntimeUpload( - sourcePath: string + sourcePath: string, + totalBytesBefore = 0 ): Promise { const resolvedSource = resolve(sourcePath) // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat/readFile just like local copy imports. + // authorize before lstat just like local copy imports. authorizeExternalPath(resolvedSource) let sourceStat: Awaited> @@ -52,8 +70,8 @@ export async function stageOneSourceForRuntimeUpload( } try { const entries = sourceStat.isDirectory() - ? await stageDirectoryEntries(resolvedSource) - : [(await stageFileEntry(resolvedSource, '')).entry] + ? await stageDirectoryEntries(resolvedSource, totalBytesBefore) + : [(await stageFileEntry(resolvedSource, '', { totalBytesBefore })).entry] return { sourcePath, status: 'staged', @@ -73,9 +91,12 @@ export async function stageOneSourceForRuntimeUpload( } } -async function stageDirectoryEntries(rootPath: string): Promise { +async function stageDirectoryEntries( + rootPath: string, + totalBytesBefore: number +): Promise { const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }] - let totalBytes = 0 + let totalBytes = totalBytesBefore const rootRealPath = await realpath(rootPath) async function visit(dirPath: string): Promise { @@ -126,52 +147,52 @@ async function stageDirectoryEntries(rootPath: string): Promise { const statResult = await lstat(filePath) const displayPath = normalizeRelativeUploadPath(relativePath) + // Why: a dropped file's relative path is '', so errors would name nothing. + // The entry keeps '' — only the message falls back to the file's own name. + const displayName = displayPath || basename(filePath) if (statResult.isSymbolicLink()) { - throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayPath}'`) + throw new RuntimeUploadSymlinkError(`Symlink not allowed in '${displayName}'`) } if (!statResult.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } - if (options?.rootRealPath) { - await assertRealPathInsideRoot(options.rootRealPath, filePath, displayPath) + if (options.rootRealPath) { + await assertRealPathInsideRoot(options.rootRealPath, filePath, displayName) } - const initialTotalBytes = - options?.totalBytesBefore === undefined - ? statResult.size - : options.totalBytesBefore + statResult.size - assertRemoteUploadBudget(relativePath, statResult.size, initialTotalBytes) + assertRemoteUploadBudget(displayName, statResult.size, options.totalBytesBefore + statResult.size) const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { const openedStat = await fileHandle.stat() if (!openedStat.isFile()) { - throw new Error(`Unsupported file type in '${displayPath}'`) + throw new Error(`Unsupported file type in '${displayName}'`) } if ( openedStat.size !== statResult.size || (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) || (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev) ) { - throw new Error(`File changed during upload staging: '${displayPath}'`) - } - const totalBytes = - options?.totalBytesBefore === undefined - ? openedStat.size - : options.totalBytesBefore + openedStat.size - assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) - const buffer = await fileHandle.readFile() - const afterReadStat = await fileHandle.stat() - if (afterReadStat.size !== openedStat.size) { - throw new Error(`File changed during upload staging: '${displayPath}'`) + throw new Error(`File changed during upload staging: '${displayName}'`) } + assertRemoteUploadBudget( + displayName, + openedStat.size, + options.totalBytesBefore + openedStat.size + ) + // Why: bytes are read slice-by-slice at upload time, so staging records the + // identity the streamer re-checks rather than the body itself. Size alone + // would let a same-size replacement slip through between the two calls. return { entry: { relativePath: displayPath, kind: 'file', - contentBase64: buffer.toString('base64') + byteLength: openedStat.size, + inode: openedStat.ino, + deviceId: openedStat.dev, + modifiedAtMs: openedStat.mtimeMs }, byteLength: openedStat.size } @@ -197,15 +218,21 @@ async function assertRealPathInsideRoot( } function assertRemoteUploadBudget( - relativePath: string, + displayName: string, fileBytes: number, totalBytes: number ): void { if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new Error(`'${relativePath}' is too large for remote import`) + throw new Error( + `'${displayName}' is ${formatByteCeiling(fileBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) } if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { - throw new Error('Remote import is too large') + throw new Error( + `This import is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)} total remote import limit` + ) } } diff --git a/src/main/ipc/renderer-lifetime-abort.test.ts b/src/main/ipc/renderer-lifetime-abort.test.ts new file mode 100644 index 00000000000..9e5b0f9a7d9 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.test.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it } from 'vitest' +import { + abortWhenRendererGone, + RENDERER_GONE_MESSAGE, + type RendererLifetimeSender +} from './renderer-lifetime-abort' + +function fakeSender(): RendererLifetimeSender & EventEmitter { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: EventEmitter implements the once/on/removeListener surface this helper uses, and those three are all it calls; WebContents' overloaded signatures cannot be satisfied structurally. + return new EventEmitter() as RendererLifetimeSender & EventEmitter +} + +describe('abortWhenRendererGone', () => { + it('aborts when the renderer is destroyed', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + expect(signal.aborted).toBe(false) + sender.emit('destroyed') + + expect(signal.aborted).toBe(true) + expect(String(signal.reason)).toContain(RENDERER_GONE_MESSAGE) + }) + + it('aborts when the render process is gone', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('render-process-gone') + + expect(signal.aborted).toBe(true) + }) + + it('aborts once a reload has replaced the document, not on in-app route changes', () => { + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: true, + url: 'file:///app#x' + }) + sender.emit('did-navigate-in-page', 'file:///app#x') + expect(signal.aborted).toBe(false) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///app' + }) + sender.emit('did-navigate', 'file:///app', 200, 'OK') + expect(signal.aborted).toBe(true) + }) + + it('ignores a main-frame navigation that starts but is blocked before it commits', () => { + // Why: Electron emits did-start-navigation before will-navigate gets to + // preventDefault() an external link or a stray file drop; the renderer + // document survives those, so the upload must too. + const sender = fakeSender() + const { signal } = abortWhenRendererGone(sender) + + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'https://example.invalid/' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'https://example.invalid/') + sender.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: 'file:///Users/me/dropped.png' + }) + sender.emit('will-navigate', { defaultPrevented: true }, 'file:///Users/me/dropped.png') + + expect(signal.aborted).toBe(false) + }) + + it('leaves no listeners on a long-lived renderer once disposed', () => { + const sender = fakeSender() + const { dispose } = abortWhenRendererGone(sender) + + expect(sender.listenerCount('destroyed')).toBe(1) + dispose() + dispose() + + expect(sender.listenerCount('destroyed')).toBe(0) + expect(sender.listenerCount('render-process-gone')).toBe(0) + expect(sender.listenerCount('did-navigate')).toBe(0) + }) +}) diff --git a/src/main/ipc/renderer-lifetime-abort.ts b/src/main/ipc/renderer-lifetime-abort.ts new file mode 100644 index 00000000000..213abab8f47 --- /dev/null +++ b/src/main/ipc/renderer-lifetime-abort.ts @@ -0,0 +1,45 @@ +import type { WebContents } from 'electron' + +export type RendererLifetimeSender = Pick + +export const RENDERER_GONE_MESSAGE = 'The window that started this upload went away' + +/** + * Abort signal that fires when the calling renderer goes away. + * + * Work the renderer used to do itself died with it. Once it moves into main, + * nothing stops a long transfer from outliving the window that asked for it, + * so the caller's lifetime has to be wired up explicitly. + * + * Always `dispose()` in a finally — otherwise every call leaks a listener on a + * long-lived WebContents. + */ +export function abortWhenRendererGone(sender: RendererLifetimeSender): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const abort = (): void => controller.abort(new Error(RENDERER_GONE_MESSAGE)) + let disposed = false + + sender.once('destroyed', abort) + sender.once('render-process-gone', abort) + // Why: did-start-navigation also fires for navigations that will-navigate then + // blocks — an external link, a stray file drop — and the renderer survives + // those. did-navigate fires only once a new document has replaced the caller, + // and never for same-document route changes inside the live app. + sender.once('did-navigate', abort) + + return { + signal: controller.signal, + dispose: () => { + if (disposed) { + return + } + disposed = true + sender.removeListener('destroyed', abort) + sender.removeListener('render-process-gone', abort) + sender.removeListener('did-navigate', abort) + } + } +} diff --git a/src/main/ipc/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environment-connectivity-handlers.ts index d461b1d080a..84d2754d556 100644 --- a/src/main/ipc/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environment-connectivity-handlers.ts @@ -27,7 +27,8 @@ import { import { clearRuntimeEnvironmentManualDisconnect, isRuntimeEnvironmentManuallyDisconnected, - markRuntimeEnvironmentManuallyDisconnected + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE } from './runtime-environment-manual-disconnect' import { callRuntimeEnvironment, @@ -42,7 +43,7 @@ function manuallyDisconnectedResponse( ok: false, error: { code: 'runtime_manually_disconnected', - message: 'Runtime environment is manually disconnected.' + message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE }, _meta: { runtimeId: environment.runtimeId } } diff --git a/src/main/ipc/runtime-environment-manual-disconnect.ts b/src/main/ipc/runtime-environment-manual-disconnect.ts index f9f94e7f438..31c300895df 100644 --- a/src/main/ipc/runtime-environment-manual-disconnect.ts +++ b/src/main/ipc/runtime-environment-manual-disconnect.ts @@ -1,5 +1,7 @@ const manuallyDisconnectedEnvironmentIds = new Set() +export const RUNTIME_MANUALLY_DISCONNECTED_MESSAGE = 'Runtime environment is manually disconnected.' + export function markRuntimeEnvironmentManuallyDisconnected(environmentId: string): void { manuallyDisconnectedEnvironmentIds.add(environmentId) } diff --git a/src/main/ipc/runtime-import-limits.test.ts b/src/main/ipc/runtime-import-limits.test.ts new file mode 100644 index 00000000000..2eaeae603b0 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' + +describe('formatByteCeiling', () => { + it('renders a size one byte over a ceiling as larger than the ceiling', () => { + // "is 2 GB, over the 2 GB limit" reads like a broken check, not a big file. + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)).toBe('2 GB') + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES + 1)).toBe('2.1 GB') + }) + + it('leaves an exact ceiling as a whole number', () => { + expect(formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)).toBe('8 GB') + expect(formatByteCeiling(1024)).toBe('1 KB') + }) + + it('scales through the units', () => { + expect(formatByteCeiling(512)).toBe('512 B') + expect(formatByteCeiling(1024 * 1024)).toBe('1 MB') + expect(formatByteCeiling(1024 ** 4)).toBe('1 TB') + }) + + it('rounds up rather than to nearest', () => { + expect(formatByteCeiling(1024 * 1024 + 1)).toBe('1.1 MB') + }) + + it('does not crash on zero', () => { + expect(formatByteCeiling(0)).toBe('0 B') + }) +}) diff --git a/src/main/ipc/runtime-import-limits.ts b/src/main/ipc/runtime-import-limits.ts new file mode 100644 index 00000000000..80fd680a24e --- /dev/null +++ b/src/main/ipc/runtime-import-limits.ts @@ -0,0 +1,18 @@ +// Why: staging streams slices at upload time and never holds a whole file, so +// these are user-safety ceilings on an unattended transfer, not memory guards. +// They stay until the drop UI can show progress and cancel a running upload. +export const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 + +/** Rounds up, so a size over a ceiling never renders as the ceiling itself. */ +export function formatByteCeiling(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts new file mode 100644 index 00000000000..4d11bc18652 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -0,0 +1,438 @@ +import { lstat, mkdtemp, mkdir, rename, rm, symlink, utimes, 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 { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits + +type ChunkCall = { + relativePath: string + contentBase64: string + append: boolean + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedExecutionHostId?: string +} +type RuntimeCallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: ChunkCall, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: RuntimeCallOptions + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture +// would allocate gigabytes on Windows. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 2 * 1024 * 1024 +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { + clearRuntimeEnvironmentManualDisconnect, + markRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} = await import('./runtime-environment-manual-disconnect') + +let workDir: string + +function chunkCalls(): ChunkCall[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +function uploadedBytes(): Buffer { + return Buffer.concat(chunkCalls().map((call) => Buffer.from(call.contentBase64, 'base64'))) +} + +/** Mirrors what staging records, so tests exercise the real identity contract. */ +async function stagedIdentity(filePath: string): Promise { + const stat = await lstat(filePath) + return { + byteLength: stat.size, + inode: stat.ino, + deviceId: stat.dev, + modifiedAtMs: stat.mtimeMs + } +} + +async function baseArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: await stagedIdentity(entryPath ? join(sourceRootPath, entryPath) : sourceRootPath), + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +/** A path whose identity was never measured; every field is deliberately absent. */ +function unstagedArgs(sourceRootPath: string, entryPath?: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: entryPath ?? '', + expected: { byteLength: 0, inode: 0, deviceId: 0, modifiedAtMs: 0 }, + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-stream-')) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('streamExternalFileToRuntime', () => { + it('sends a file larger than the old 25 MB cap as ordered append-only slices', async () => { + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 1234 + const contents = Buffer.alloc(size) + for (let index = 0; index < size; index += 1) { + contents[index] = index % 251 + } + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, contents) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + expect(calls).toHaveLength(3) + expect(calls.map((call) => call.append)).toEqual([false, true, true]) + expect(uploadedBytes().equals(contents)).toBe(true) + }) + + it('refuses a source whose size no longer matches what staging measured', async () => { + const filePath = join(workDir, 'grown.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const staged = await stagedIdentity(filePath) + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow("File changed since it was staged: 'grown.bin'") + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source swapped for a different file of the same size', async () => { + const filePath = join(workDir, 'swapped.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // A rename-into-place keeps the size and changes the inode. + const decoyPath = join(workDir, 'decoy.bin') + await writeFile(decoyPath, Buffer.alloc(2048, 0x42)) + await rename(decoyPath, filePath) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a source rewritten in place at the same size after staging', async () => { + const filePath = join(workDir, 'rewritten.bin') + await writeFile(filePath, Buffer.alloc(2048, 0x41)) + const staged = await stagedIdentity(filePath) + + // Same inode and size; only the modification time moves. + await writeFile(filePath, Buffer.alloc(2048, 0x42)) + const bumped = new Date(staged.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), expected: staged }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('aborts when the source is rewritten at the same size mid-transfer', async () => { + const filePath = join(workDir, 'racing.bin') + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + await writeFile(filePath, Buffer.alloc(size, 0x41)) + const args = await baseArgs(filePath) + + let rewritten = false + callRuntimeEnvironment.mockImplementation(async () => { + if (!rewritten) { + rewritten = true + await writeFile(filePath, Buffer.alloc(size, 0x42)) + const bumped = new Date(args.expected.modifiedAtMs + 5_000) + await utimes(filePath, bumped, bumped) + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('accepts a source that still matches its staged identity', async () => { + const filePath = join(workDir, 'same.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 2048 + }) + }) + + it('refuses a file over the ceiling and names the source, not the temp path', async () => { + const filePath = join(workDir, 'clip.mp4') + await writeFile(filePath, Buffer.alloc(3 * 1024 * 1024)) + + // Why: relativePath here is '.upload.tmp', a path the user never chose. + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + "'clip.mp4' is 3 MB, over the 2 MB per-file remote import limit" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('never buffers more than one slice per chunk', async () => { + const filePath = join(workDir, 'sliced.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) + + await streamExternalFileToRuntime(await baseArgs(filePath)) + + for (const call of chunkCalls()) { + expect(Buffer.from(call.contentBase64, 'base64').byteLength).toBeLessThanOrEqual( + RUNTIME_UPLOAD_SLICE_BYTES + ) + } + }) + + it('creates an empty destination for a zero-byte source', async () => { + const filePath = join(workDir, 'empty.txt') + await writeFile(filePath, '') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).resolves.toEqual({ + byteLength: 0 + }) + + expect(chunkCalls()).toEqual([expect.objectContaining({ append: false, contentBase64: '' })]) + }) + + it('refuses to finish a zero-byte upload whose source gained content mid-write', async () => { + const filePath = join(workDir, 'grows.txt') + await writeFile(filePath, '') + const args = await baseArgs(filePath) + + callRuntimeEnvironment.mockImplementation(async () => { + await writeFile(filePath, 'content arrived during the empty write') + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow('File changed during upload') + }) + + it('carries the pairing revision and runtime id on every chunk', async () => { + const filePath = join(workDir, 'guarded.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedEnvironmentPairingRevision: 41, + expectedEnvironmentRuntimeId: 'runtime-7' + }) + + const guards = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , revision, , options]) => ({ + revision, + runtimeId: options?.expectedEnvironmentRuntimeId + })) + expect(guards).toEqual([ + { revision: 41, runtimeId: 'runtime-7' }, + { revision: 41, runtimeId: 'runtime-7' } + ]) + }) + + it('stops mid-transfer when the caller aborts instead of streaming the rest', async () => { + const filePath = join(workDir, 'abandoned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 4)) + const controller = new AbortController() + + callRuntimeEnvironment.mockImplementation(async () => { + controller.abort(new Error('window closed')) + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + // One slice went out before the abort; the other three never do. + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses to start once the caller has already aborted', async () => { + const filePath = join(workDir, 'never.bin') + await writeFile(filePath, Buffer.alloc(1024)) + const controller = new AbortController() + controller.abort(new Error('window closed')) + + await expect( + streamExternalFileToRuntime({ ...(await baseArgs(filePath)), signal: controller.signal }) + ).rejects.toThrow('window closed') + expect(chunkCalls()).toHaveLength(0) + }) + + it('passes the abort signal to every chunk so an in-flight request is cancelled', async () => { + const filePath = join(workDir, 'signalled.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + const controller = new AbortController() + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + signal: controller.signal + }) + + const signals = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , , , options]) => options?.signal) + expect(signals).toEqual([controller.signal, controller.signal]) + }) + + it('stops at the failing chunk instead of sending the rest of the file', async () => { + const filePath = join(workDir, 'fails.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3)) + callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} }) + callRuntimeEnvironment.mockResolvedValueOnce({ + id: 'x', + ok: false, + error: { code: 'write_failed', message: 'disk full' } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow('disk full') + expect(chunkCalls()).toHaveLength(2) + }) + + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { + const targetPath = join(workDir, 'secret.txt') + await writeFile(targetPath, 'secret') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(streamExternalFileToRuntime(unstagedArgs(linkPath))).rejects.toThrow( + 'Symlink not allowed' + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a regular file reached through a symlinked directory inside the root', + async () => { + // Why: the symlink guard only lstats the entry itself, which sees a plain + // file here — realpath containment is the only thing that catches this. + const outsideDir = join(workDir, 'outside') + await mkdir(outsideDir) + await writeFile(join(outsideDir, 'secret.txt'), 'secret') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsideDir, join(rootPath, 'sub')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'sub/secret.txt')) + ).rejects.toThrow('Path escaped upload root during upload') + expect(chunkCalls()).toHaveLength(0) + } + ) + + it('forwards the host ownership expectations into every chunk', async () => { + const filePath = join(workDir, 'owned.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...(await baseArgs(filePath)), + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + + const calls = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) + expect(calls).toHaveLength(2) + for (const params of calls) { + expect(params).toMatchObject({ + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 5, + expectedExecutionHostId: 'ssh:ssh-1' + }) + } + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a symlinked directory entry before it reaches the containment check', + async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime(unstagedArgs(rootPath, 'escape.txt')) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + } + ) +}) + +describe('manual disconnect during a transfer', () => { + afterEach(() => { + clearRuntimeEnvironmentManualDisconnect('env-1') + }) + + it('stops at the next slice once the environment is manually disconnected', async () => { + const filePath = join(workDir, 'disconnect.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3, 7)) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + markRuntimeEnvironmentManuallyDisconnected('env-1') + } + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(1) + }) + + it('refuses the first slice when the environment is already disconnected', async () => { + const filePath = join(workDir, 'disconnected.bin') + await writeFile(filePath, Buffer.alloc(16, 1)) + markRuntimeEnvironmentManuallyDisconnected('env-1') + + await expect(streamExternalFileToRuntime(await baseArgs(filePath))).rejects.toThrow( + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE + ) + expect(chunkCalls()).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts new file mode 100644 index 00000000000..28271774ce0 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -0,0 +1,213 @@ +import { constants, type Stats } from 'node:fs' +import { lstat, open, realpath } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { + RuntimeUploadFileStreamRequest, + StagedRuntimeUploadFileIdentity +} from '../../shared/runtime-upload-staging-contract' +import { authorizeExternalPath } from './filesystem-auth' +import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +// Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire +// as exactly 512 KiB — the chunk size the renderer used before streaming. +export const RUNTIME_UPLOAD_SLICE_BYTES = 384 * 1024 + +const RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS = 30_000 + +export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { + /** Resolved environment id, not a selector: the manual-disconnect check keys on it. */ + environmentId: string + userDataPath: string + /** Aborts the transfer; the caller's lifetime is what raises it today. */ + signal?: AbortSignal +} + +/** + * Stream one client-local file to a runtime environment in slices. + * + * Replaces reading the whole file into memory and base64-encoding it before the + * first byte moves. Peak memory is one slice, so imports are no longer bounded + * by main-process heap. + */ +export async function streamExternalFileToRuntime( + args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) + + // Why: parity with staging — an OS drop authorizes the paths it hands over. + authorizeExternalPath(sourcePath) + + // Why: relativePath is the hidden .orca-upload- temp destination, so a + // dropped file names its source instead of a path the user never chose. + const displayPath = args.entryRelativePath || basename(args.sourceRootPath) + const lstatResult = await lstat(sourcePath) + if (lstatResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!lstatResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (args.entryRelativePath) { + await assertEntryInsideRoot(args.sourceRootPath, sourcePath, displayPath) + } + assertMatchesStagedIdentity(lstatResult, args.expected, displayPath) + + args.signal?.throwIfAborted() + + const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (!isSameFile(openedStat, lstatResult)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + // Why: the handle is what the slices are read from, so the staged identity + // has to hold here too — checking only the pre-open lstat leaves a window + // where the path is swapped between lstat and open. + assertMatchesStagedIdentity(openedStat, args.expected, displayPath) + + const totalBytes = openedStat.size + // Why: enforced again where the bytes actually move. Staging is a separate + // call, so the ceiling only holds here if this boundary checks it too. + if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error( + `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) + } + if (totalBytes === 0) { + // Why: a zero-byte source produces no slices, but the destination still + // has to exist before commitUpload renames it into place. + await sendChunk(args, '', false) + } else { + const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) + let offset = 0 + while (offset < totalBytes) { + // Why: checked per slice, so an abort stops the transfer at the next + // boundary instead of after the whole file has moved. + args.signal?.throwIfAborted() + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + if (bytesRead === 0) { + throw new Error(`File truncated during upload: '${displayPath}'`) + } + await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + offset += bytesRead + } + } + + // Why: the destination is a temp path the caller commits, so a source + // rewritten mid-transfer is caught before anything lands at the final path. + // mtime catches an in-place edit that kept the size. An empty source runs + // this too: its chunk is still a round trip the source can change during. + const afterReadStat = await handle.stat() + if (afterReadStat.mtimeMs !== openedStat.mtimeMs || !isSameFile(afterReadStat, openedStat)) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + return { byteLength: totalBytes } + } finally { + await handle.close() + } +} + +/** + * Refuse a source that no longer matches what staging measured. + * + * Inode and device are compared only when both sides report one, because some + * filesystems leave them at 0; size and mtime then carry the check alone. + */ +function assertMatchesStagedIdentity( + observed: Stats, + expected: StagedRuntimeUploadFileIdentity, + displayPath: string +): void { + const changed = + observed.size !== expected.byteLength || + observed.mtimeMs !== expected.modifiedAtMs || + (expected.inode !== 0 && observed.ino !== 0 && observed.ino !== expected.inode) || + (expected.deviceId !== 0 && observed.dev !== 0 && observed.dev !== expected.deviceId) + if (changed) { + throw new Error(`File changed since it was staged: '${displayPath}'`) + } +} + +/** Same inode on the same device, where the filesystem reports them. */ +function isSameFile(a: Stats, b: Stats): boolean { + return ( + a.size === b.size && + (a.ino === 0 || b.ino === 0 || a.ino === b.ino) && + (a.dev === 0 || b.dev === 0 || a.dev === b.dev) + ) +} + +/** Append one base64 slice, carrying the host guards that must hold per chunk. */ +async function sendChunk( + args: RuntimeUploadFileStreamArgs, + contentBase64: string, + append: boolean +): Promise { + // Why: the renderer's per-chunk calls went through an IPC handler that refuses + // a manually disconnected environment. The loop lives in main now, so it makes + // the same check, or a disconnect mid-upload keeps pushing bytes to that host. + if (isRuntimeEnvironmentManuallyDisconnected(args.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + args.userDataPath, + args.environmentId, + 'files.writeBase64Chunk', + { + worktree: args.worktree, + relativePath: args.relativePath, + contentBase64, + append, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS, + // Why: re-checked per chunk, so a re-pair mid-upload aborts instead of + // appending the rest of the file on a different host. + args.expectedEnvironmentPairingRevision, + undefined, + { + // Why: a replacement runtime keeps the pairing but invalidates its + // predecessor's capability proof, so the identity rides every chunk too. + expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId, + signal: args.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } +} + +function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { + // Why: staging resolves before authorizing, so the streamer has to agree on + // the same absolute path or the two checks can disagree. + const root = resolve(sourceRootPath) + return entryRelativePath ? join(root, entryRelativePath) : root +} + +async function assertEntryInsideRoot( + sourceRootPath: string, + candidatePath: string, + displayPath: string +): Promise { + const rootRealPath = await realpath(sourceRootPath) + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + // Why: `..name` is a valid child path; only `..` and `../...` escape. + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { + throw new Error(`Path escaped upload root during upload: '${displayPath}'`) + } +} diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts new file mode 100644 index 00000000000..0c2bd457b1f --- /dev/null +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -0,0 +1,383 @@ +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + truncate, + 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 { FileWriteBase64Chunk } from '../../shared/rpc-contract/files-mutation-params' +import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' + +// Why: real limits, real host write flags ('wx' then 'a') and the real chunk +// schema — the slice loop is exercised exactly at the boundaries it must respect. +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +type ChunkParams = { relativePath: string; contentBase64: string; append: boolean } +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type CallArgs = [ + userDataPath: string, + environmentId: string, + method: string, + params: ChunkParams, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: CallOptions +] + +const callRuntimeEnvironment = vi.fn<(...args: CallArgs) => Promise>() +// Why: vi.fn retains every call's params; a 2 GiB stream would pin ~2.8 GB of +// base64 in mock.calls and masquerade as a leak. Big tests swap in a plain fn. +let transportImpl: (...args: CallArgs) => Promise = (...args) => + callRuntimeEnvironment(...args) +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: CallArgs) => transportImpl(...args) +})) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') +const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging') +const { REMOTE_IMPORT_MAX_FILE_BYTES, REMOTE_IMPORT_MAX_TOTAL_BYTES, formatByteCeiling } = + await import('./runtime-import-limits') + +const SLICE = RUNTIME_UPLOAD_SLICE_BYTES +const WIRE_CHUNK_CHARS = 512 * 1024 +const OK = { id: 'x', ok: true, result: {}, _meta: {} } + +let workDir: string +let remoteDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-bounds-')) + remoteDir = join(workDir, 'remote') + await mkdir(remoteDir) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue(OK) + transportImpl = (...args) => callRuntimeEnvironment(...args) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +function chunkCalls(): ChunkParams[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params) +} + +/** Mirrors the host: first chunk is an exclusive create, appends open with 'a'. */ +function installRealHostWrites(): void { + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, params) => { + if (method === 'files.writeBase64Chunk') { + const parsed = FileWriteBase64Chunk.parse({ worktree: 'wt-1', ...params }) + await writeFile( + join(remoteDir, parsed.relativePath), + Buffer.from(parsed.contentBase64, 'base64'), + { + flag: parsed.append ? 'a' : 'wx' + } + ) + } + return OK + }) +} + +async function identityOf(path: string): Promise { + const s = await stat(path) + return { byteLength: s.size, inode: s.ino, deviceId: s.dev, modifiedAtMs: s.mtimeMs } +} + +async function argsFor(sourceRootPath: string, entryRelativePath = '', relativePath = 'dest.tmp') { + const target = entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath, + expected: await identityOf(target), + worktree: 'wt-1', + relativePath, + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'rt-1' + } +} + +function patterned(size: number, seed: number): Buffer { + const buffer = Buffer.allocUnsafe(size) + for (let i = 0; i < size; i += 1) { + buffer[i] = (i * 31 + seed) & 0xff + } + return buffer +} + +describe('slice boundaries', () => { + const sizes = [ + 1, + 2, + 3, + 4, + SLICE - 1, + SLICE, + SLICE + 1, + 2 * SLICE - 1, + 2 * SLICE, + 2 * SLICE + 1, + 3 * SLICE + 7 + ] + + for (const size of sizes) { + it(`streams ${size} bytes as ceil(size/slice) schema-valid chunks that the host reassembles exactly`, async () => { + installRealHostWrites() + const contents = patterned(size, size) + const source = join(workDir, `s-${size}.bin`) + await writeFile(source, contents) + const dest = `dest-${size}.tmp` + + await expect(streamExternalFileToRuntime(await argsFor(source, '', dest))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + const expectedChunks = Math.ceil(size / SLICE) + expect(calls).toHaveLength(expectedChunks) + expect(calls.map((c) => c.append)).toEqual(calls.map((_, i) => i > 0)) + for (const [index, call] of calls.entries()) { + const isLast = index === calls.length - 1 + expect(call.contentBase64.length).toBeLessThanOrEqual(WIRE_CHUNK_CHARS) + if (!isLast) { + expect(call.contentBase64.length).toBe(WIRE_CHUNK_CHARS) + } + expect(call.relativePath).toBe(dest) + } + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(contents)).toBe(true) + }) + } + + it('sends a zero-byte file as one empty exclusive create the host schema accepts', async () => { + installRealHostWrites() + const source = join(workDir, 'empty.bin') + await writeFile(source, '') + + await expect( + streamExternalFileToRuntime(await argsFor(source, '', 'empty.tmp')) + ).resolves.toEqual({ + byteLength: 0 + }) + expect(chunkCalls()).toHaveLength(1) + expect(chunkCalls()[0]).toMatchObject({ + relativePath: 'empty.tmp', + contentBase64: '', + append: false + }) + expect((await stat(join(remoteDir, 'empty.tmp'))).size).toBe(0) + }) + + it('carries the pairing revision, runtime id and signal on every chunk', async () => { + const source = join(workDir, 'guards.bin') + await writeFile(source, patterned(2 * SLICE + 1, 3)) + + await streamExternalFileToRuntime(await argsFor(source)) + + const chunkInvocations = callRuntimeEnvironment.mock.calls.filter( + ([, , method]) => method === 'files.writeBase64Chunk' + ) + expect(chunkInvocations).toHaveLength(3) + for (const [, environmentId, , , timeoutMs, revision, envelope, options] of chunkInvocations) { + expect(environmentId).toBe('env-1') + expect(timeoutMs).toBe(30_000) + expect(revision).toBe(7) + expect(envelope).toBeUndefined() + expect(options?.expectedEnvironmentRuntimeId).toBe('rt-1') + } + }) +}) + +describe('staging → streaming end to end on a real filesystem', () => { + it('streams every staged entry of a dropped directory using the identity staging recorded', async () => { + installRealHostWrites() + const root = join(workDir, 'drop me') + await mkdir(join(root, 'sub', 'deeper'), { recursive: true }) + const files: Record = { + 'a.txt': Buffer.from('alpha'), + '..keep': Buffer.from('dot-dot-prefixed name is a valid child'), + 'héllo wörld.bin': patterned(SLICE, 9), + 'sub/empty': Buffer.alloc(0), + 'sub/deeper/big.bin': patterned(2 * SLICE + 5, 11) + } + for (const [rel, body] of Object.entries(files)) { + await writeFile(join(root, rel), body) + } + + const staged = await stageOneSourceForRuntimeUpload(root) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const fileEntries = staged.entries.filter((e) => e.kind === 'file') + expect(fileEntries.map((e) => e.relativePath).sort()).toEqual(Object.keys(files).sort()) + + for (const entry of fileEntries) { + if (entry.kind !== 'file') { + continue + } + const dest = `up-${entry.relativePath.replace(/[^a-z0-9]/gi, '_')}.tmp` + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + }, + worktree: 'wt-1', + relativePath: dest + }) + ).resolves.toEqual({ byteLength: files[entry.relativePath]!.length }) + const remote = await readFile(join(remoteDir, dest)) + expect(remote.equals(files[entry.relativePath]!)).toBe(true) + } + }) + + it('streams a dropped single file using the identity staging recorded', async () => { + installRealHostWrites() + const source = join(workDir, 'single.bin') + const body = patterned(SLICE + 1, 5) + await writeFile(source, body) + + const staged = await stageOneSourceForRuntimeUpload(source) + expect(staged.status).toBe('staged') + if (staged.status !== 'staged') { + return + } + const entry = staged.entries[0]! + expect(entry.kind).toBe('file') + if (entry.kind !== 'file') { + return + } + + await expect( + streamExternalFileToRuntime({ + userDataPath: '/u', + environmentId: 'env-1', + sourceRootPath: staged.sourcePath, + entryRelativePath: entry.relativePath, + expected: entry, + worktree: 'wt-1', + relativePath: 'single.tmp' + }) + ).resolves.toEqual({ byteLength: body.length }) + expect((await readFile(join(remoteDir, 'single.tmp'))).equals(body)).toBe(true) + }) +}) + +describe('source mutation during transfer', () => { + it('rejects a source that grows during the transfer and never claims success', async () => { + const source = join(workDir, 'growing.bin') + await writeFile(source, patterned(2 * SLICE, 1)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await appendFile(source, 'extra') + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed during upload: 'growing.bin'" + ) + }) + + it('rejects a source truncated during the transfer instead of sending a short file', async () => { + const source = join(workDir, 'shrinking.bin') + await writeFile(source, patterned(3 * SLICE, 2)) + const args = await argsFor(source) + callRuntimeEnvironment.mockImplementation(async (_u, _e, method) => { + if (method === 'files.writeBase64Chunk' && chunkCalls().length === 1) { + await truncate(source, SLICE) + } + return OK + }) + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File truncated during upload: 'shrinking.bin'" + ) + expect(chunkCalls().length).toBeLessThan(3) + }) + + it('accepts a staged identity whose inode and device are unreported (0) when size and mtime match', async () => { + const source = join(workDir, 'no-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: 0, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).resolves.toEqual({ byteLength: 10 }) + }) + + it('still refuses a wrong inode when only the device is unreported', async () => { + const source = join(workDir, 'wrong-ino.bin') + await writeFile(source, patterned(10, 4)) + const args = await argsFor(source) + args.expected = { ...args.expected, inode: args.expected.inode + 1, deviceId: 0 } + + await expect(streamExternalFileToRuntime(args)).rejects.toThrow( + "File changed since it was staged: 'wrong-ino.bin'" + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('stops before the next slice when the signal aborts while a chunk is in flight', async () => { + const source = join(workDir, 'abort.bin') + await writeFile(source, patterned(3 * SLICE, 6)) + const controller = new AbortController() + callRuntimeEnvironment.mockImplementation(async (_u, _e, method, _p, _t, _r, _env, options) => { + if (method !== 'files.writeBase64Chunk') { + return OK + } + if (chunkCalls().length === 2) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true + }) + controller.abort(new Error('window gone')) + }) + } + return OK + }) + + await expect( + streamExternalFileToRuntime({ ...(await argsFor(source)), signal: controller.signal }) + ).rejects.toThrow('window gone') + expect(chunkCalls()).toHaveLength(2) + }) +}) + +describe('formatByteCeiling bounds', () => { + it.each([ + [0, '0 B'], + [1, '1 B'], + [1023, '1023 B'], + [1024, '1 KB'], + [1025, '1.1 KB'], + [25 * 1024 * 1024, '25 MB'], + [25 * 1024 * 1024 + 1, '25.1 MB'], + [REMOTE_IMPORT_MAX_FILE_BYTES, '2 GB'], + [REMOTE_IMPORT_MAX_FILE_BYTES + 1, '2.1 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES, '8 GB'], + [REMOTE_IMPORT_MAX_TOTAL_BYTES + 1, '8.1 GB'], + [1024 ** 5, '1024 TB'] + ])('%i → %s', (bytes, text) => { + expect(formatByteCeiling(bytes)).toBe(text) + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.test.ts b/src/main/ipc/runtime-upload-temp-sweep.test.ts new file mode 100644 index 00000000000..06e7fe9282c --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' + +const callRuntimeEnvironment = + vi.fn< + ( + userDataPath: string, + environmentId: string, + method: string, + params: { relativePath: string; recursive: boolean }, + timeoutMs?: number, + expectedEnvironmentPairingRevision?: number, + envelope?: unknown, + options?: { expectedEnvironmentRuntimeId?: string } + ) => unknown + >() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: Parameters) => + callRuntimeEnvironment(...args) +})) + +const { sweepAbandonedRuntimeUploadTempPath } = await import('./runtime-upload-temp-sweep') + +const request: RuntimeUploadFileStreamRequest = { + environmentId: 'env-1', + sourceRootPath: '/Users/me/clip.mp4', + entryRelativePath: '', + expected: { byteLength: 4, inode: 1, deviceId: 2, modifiedAtMs: 3 }, + worktree: 'id:wt-1', + relativePath: 'uploads/.clip.mp4.orca-upload-abc', + expectedEnvironmentPairingRevision: 17, + expectedEnvironmentRuntimeId: 'runtime-7', + expectedExecutionHostId: 'local' +} + +function deleteCalls(): { relativePath: string; recursive: boolean }[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.delete') + .map(([, , , params]) => params) +} + +beforeEach(() => { + vi.useFakeTimers() + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('sweepAbandonedRuntimeUploadTempPath', () => { + it('deletes twice, because a straggling append recreates the file with flag a', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + expect(deleteCalls()).toEqual([ + expect.objectContaining({ relativePath: request.relativePath, recursive: false }), + expect.objectContaining({ relativePath: request.relativePath, recursive: false }) + ]) + }) + + it('still makes the second pass when the first one fails', async () => { + callRuntimeEnvironment.mockRejectedValueOnce(new Error('connection lost')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await expect(swept).resolves.toBeUndefined() + + expect(deleteCalls()).toHaveLength(2) + }) + + it('carries the host ownership guards so it cannot delete on a re-paired host', async () => { + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + await swept + + for (const call of callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(17) + expect(call[7]?.expectedEnvironmentRuntimeId).toBe('runtime-7') + } + }) + + it('never rejects, so cleanup cannot mask the upload failure', async () => { + callRuntimeEnvironment.mockRejectedValue(new Error('runtime gone')) + + const swept = sweepAbandonedRuntimeUploadTempPath('/user-data', request) + await vi.runAllTimersAsync() + + await expect(swept).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ipc/runtime-upload-temp-sweep.ts b/src/main/ipc/runtime-upload-temp-sweep.ts new file mode 100644 index 00000000000..46150fd96e5 --- /dev/null +++ b/src/main/ipc/runtime-upload-temp-sweep.ts @@ -0,0 +1,49 @@ +import { setTimeout } from 'node:timers/promises' +import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +const RUNTIME_UPLOAD_SWEEP_ATTEMPTS = 2 +const RUNTIME_UPLOAD_SWEEP_SETTLE_MS = 250 + +/** + * Sweep an abandoned upload temp path after an abort. + * + * Aborting rejects the in-flight chunk locally, but the host may still apply + * that append — and appends open with `flag: 'a'`, which recreates the file a + * delete just removed. Slices are strictly sequential, so at most one append + * can be outstanding: a second pass after it has had time to land is enough. + * + * Best-effort throughout. The runtime may be why the upload failed, and a + * failed cleanup of a hidden temp file is not actionable. + */ +export async function sweepAbandonedRuntimeUploadTempPath( + userDataPath: string, + args: RuntimeUploadFileStreamRequest +): Promise { + for (let attempt = 0; attempt < RUNTIME_UPLOAD_SWEEP_ATTEMPTS; attempt += 1) { + if (attempt > 0) { + await setTimeout(RUNTIME_UPLOAD_SWEEP_SETTLE_MS) + } + try { + await callRuntimeEnvironment( + userDataPath, + args.environmentId, + 'files.delete', + { + worktree: args.worktree, + relativePath: args.relativePath, + recursive: false, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + 15_000, + args.expectedEnvironmentPairingRevision, + undefined, + { expectedEnvironmentRuntimeId: args.expectedEnvironmentRuntimeId } + ) + } catch { + // Nothing to escalate; the next pass (if any) still runs. + } + } +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 478f3040f72..21acebad53a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -12,6 +12,10 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { + RuntimeUploadFileStreamRequest, + StageRuntimeUploadResult +} from '../../shared/runtime-upload-staging-contract' export type ExportApi = { htmlToPdf: (args: { @@ -155,30 +159,12 @@ export type FilesystemApi = { } )[] }> - stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] }) => Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }) => Promise + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ) => Promise<{ byteLength: number }> resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 207b34d8519..05c6eaee477 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,6 +1,10 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { + RuntimeUploadFileStreamRequest, + StageRuntimeUploadResult +} from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import type { @@ -174,30 +178,11 @@ export const fsApi = { }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + }): Promise => + ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + uploadExternalFileToRuntime: ( + args: RuntimeUploadFileStreamRequest + ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index fb0f0e8e092..52f0bb83d09 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -4,6 +4,7 @@ import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revisi import { fsImportExternalPaths, fsStageExternalPathsForRuntimeUpload, + fsUploadExternalFileToRuntime, runtimeEnvironmentCall, runtimeEnvironmentTransportCall, installRuntimeFileClientEnvironment @@ -11,11 +12,65 @@ import { installRuntimeFileClientEnvironment() +const okResponse = (id: string): unknown => ({ + id, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } +}) + +const notFoundResponse = (id: string): unknown => ({ + id, + ok: false, + error: { code: 'not_found', message: 'not found' }, + _meta: { runtimeId: 'remote-runtime' } +}) + +/** Matches what main-process staging now records for a file entry. */ +const stagedFile = ( + relativePath: string, + byteLength: number, + inode: number +): Record => ({ + relativePath, + kind: 'file', + byteLength, + inode, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 +}) + +/** The upload request main receives; `never[]` mock args widen to it without a cast. */ +type UploadRequest = { + environmentId: string + sourceRootPath: string + entryRelativePath: string + expected: Record + worktree: string + relativePath: string + expectedExecutionHostId?: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} + +function uploadRequests(): UploadRequest[] { + return fsUploadExternalFileToRuntime.mock.calls.flat() +} + +const identityOf = (entry: Record): Record => ({ + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs +}) + describe('runtime file client', () => { - it('uploads a staged directory after one ownership and one cold compatibility preflight', async () => { + it('streams staged directory entries through main instead of sending base64 itself', async () => { replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 17 }]) - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + const logo = stagedFile('logo.png', 3, 101) + const large = stagedFile('large.bin', 40 * 1024 * 1024, 102) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -23,85 +78,19 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' }, - { - relativePath: 'large.bin', - kind: 'file', - contentBase64: `${firstChunk}${secondChunk}` - } - ] + entries: [{ relativePath: '', kind: 'directory' }, logo, large] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-large-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-large-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('create-dir')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('commit-large-upload')) + .mockResolvedValueOnce(okResponse('delete-large-temp')) await expect( importExternalPathsToRuntime( @@ -136,75 +125,48 @@ describe('runtime file client', () => { 'files.createDir', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.commitUpload', 'files.delete', - 'files.writeBase64Chunk', - 'files.writeBase64Chunk', 'files.commitUpload', 'files.delete' ]) - expect(transportCalls.filter((args) => args.method === 'status.get')).toHaveLength(2) + // Why: the whole point of the change — no file body crosses this boundary. + expect(transportCalls.some((args) => String(args.method).startsWith('files.writeBase64'))).toBe( + false + ) expect(transportCalls.every((args) => args.expectedEnvironmentPairingRevision === 17)).toBe( true ) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { - selector: 'env-1', - method: 'files.createDir', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, + + const uploads = uploadRequests() + expect(uploads).toHaveLength(2) + expect(uploads[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploads[0]).toEqual({ + environmentId: 'env-1', + sourceRootPath: '/Users/me/assets', + entryRelativePath: 'logo.png', + expected: identityOf(logo), + worktree: 'id:wt-1', + relativePath: uploads[0]?.relativePath, + expectedExecutionHostId: 'local', + expectedSshTargetId: undefined, + expectedSshConnectionGeneration: undefined, expectedEnvironmentPairingRevision: 17, expectedEnvironmentRuntimeId: 'remote-runtime' }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { - selector: 'env-1', - method: 'files.stat', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17 + expect(uploads[1]).toMatchObject({ + entryRelativePath: 'large.bin', + expected: identityOf(large) }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.createDirNoClobber', - params: { - worktree: 'id:wt-1', - relativePath: 'uploads/assets', - expectedExecutionHostId: 'local' - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { - params: { relativePath: string } - } - expect(smallWriteCall.params.relativePath).toMatch( - /^uploads\/assets\/\.logo\.png\.orca-upload-/ - ) + expect(uploads[1]?.relativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { selector: 'env-1', - method: 'files.writeBase64', + method: 'files.commitUpload', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - contentBase64: 'cG5n', + tempRelativePath: uploads[0]?.relativePath, + finalRelativePath: 'uploads/assets/logo.png', expectedExecutionHostId: 'local', expectedSshTargetId: undefined, expectedSshConnectionGeneration: undefined @@ -214,99 +176,11 @@ describe('runtime file client', () => { expectedEnvironmentRuntimeId: 'remote-runtime' }) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: smallWriteCall.params.relativePath, - finalRelativePath: 'uploads/assets/logo.png', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { selector: 'env-1', method: 'files.delete', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - const largeWriteParams = runtimeEnvironmentCall.mock.calls[7]?.[0].params - if ( - typeof largeWriteParams !== 'object' || - largeWriteParams === null || - !('relativePath' in largeWriteParams) || - typeof largeWriteParams.relativePath !== 'string' - ) { - throw new Error('missing large file write call') - } - const largeWriteRelativePath = largeWriteParams.relativePath - expect(largeWriteRelativePath).toMatch(/^uploads\/assets\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(8, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(9, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(10, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: largeWriteRelativePath, - finalRelativePath: 'uploads/assets/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: 17, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(11, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: largeWriteRelativePath, + relativePath: uploads[0]?.relativePath, recursive: false, expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -319,9 +193,8 @@ describe('runtime file client', () => { expect(fsImportExternalPaths).not.toHaveBeenCalled() }) - it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'AA==' + it('forwards a single staged file with the identity staging measured', async () => { + const entry = stagedFile('', 40 * 1024 * 1024, 55) fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -329,55 +202,16 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [entry] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'commit-upload', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) await expect( importExternalPathsToRuntime( @@ -401,79 +235,20 @@ describe('runtime file client', () => { ] }) - const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { - params: { relativePath: string } - } - expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: chunkWriteCall.params.relativePath, - finalRelativePath: 'uploads/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { - selector: 'env-1', - method: 'files.delete', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000, - expectedEnvironmentPairingRevision: undefined, - expectedEnvironmentRuntimeId: 'remote-runtime' - }) + const upload = uploadRequests()[0] + expect(upload?.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(upload?.sourceRootPath).toBe('/Users/me/large.bin') + expect(upload?.entryRelativePath).toBe('') + expect(upload?.expected).toEqual(identityOf(entry)) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.writeBase64' }) ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64Chunk' }) + ) }) - it('stops a chunked upload when its owner generation changes between writes', async () => { - const firstChunk = 'A'.repeat(512 * 1024) + it('does not commit an upload when the owner generation changes while it streams', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -481,7 +256,7 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}BBBBBBBB` }] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) @@ -492,22 +267,12 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-file-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockImplementationOnce(async () => { - ownerChanged = true - return { - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - } - }) + .mockResolvedValueOnce(notFoundResponse('stat-file-miss')) let ownerChanged = false + fsUploadExternalFileToRuntime.mockImplementation(async () => { + ownerChanged = true + return { byteLength: 40 * 1024 * 1024 } + }) const assertCurrent = vi.fn(() => { if (ownerChanged) { throw new Error('runtime owner generation changed') @@ -531,20 +296,14 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'files.stat', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) - expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( - expect.objectContaining({ method: 'files.delete' }) - ) }) - it('cleans up staged runtime upload temp files when a later chunk fails', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + it('cleans up the staged temp path when the streamed upload fails', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -552,49 +311,19 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [stagedFile('', 40 * 1024 * 1024, 55)] } ] }) runtimeEnvironmentCall - .mockResolvedValueOnce({ - id: 'stat-destination-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-destination-dir', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'stat-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('delete-temp')) + // Electron wraps a main-process throw; the reason must not leak that. + fsUploadExternalFileToRuntime.mockRejectedValue( + new Error("Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: disk full") + ) await expect( importExternalPathsToRuntime( @@ -610,13 +339,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!chunkCall) { - throw new Error('missing first chunk call') - } - const tempRelativePath = chunkCall.params.relativePath + const tempRelativePath = uploadRequests()[0]?.relativePath expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -645,10 +368,7 @@ describe('runtime file client', () => { status: 'staged', name: 'assets', kind: 'directory', - entries: [ - { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } - ] + entries: [{ relativePath: '', kind: 'directory' }, stagedFile('logo.png', 3, 101)] } ] }) @@ -659,36 +379,11 @@ describe('runtime file client', () => { result: { size: 0, isDirectory: true, mtime: 1 }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'stat-import-root-miss', - ok: false, - error: { code: 'not_found', message: 'not found' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'create-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-temp', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'delete-import-root', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) + .mockResolvedValueOnce(notFoundResponse('stat-import-root-miss')) + .mockResolvedValueOnce(okResponse('create-import-root')) + .mockResolvedValueOnce(okResponse('delete-temp')) + .mockResolvedValueOnce(okResponse('delete-import-root')) + fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime( @@ -704,13 +399,7 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const writeCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } - | undefined - if (!writeCall) { - throw new Error('missing failed file write call') - } - expect(writeCall.params.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploadRequests()[0]?.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ selector: 'env-1', method: 'files.delete', @@ -763,6 +452,7 @@ describe('runtime file client', () => { expectedSshConnectionGeneration: 5 }) expect(fsStageExternalPathsForRuntimeUpload).not.toHaveBeenCalled() + expect(fsUploadExternalFileToRuntime).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 844080626be..321c6abe6d1 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -54,6 +54,7 @@ export const fsFinishDownloadedFile: PreloadStub = vi.fn() export const fsCancelDownloadedFile: PreloadStub = vi.fn() export const fsImportExternalPaths: PreloadStub = vi.fn() export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() +export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -88,6 +89,8 @@ export function installRuntimeFileClientEnvironment(): void { fsCancelDownloadedFile.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() + fsUploadExternalFileToRuntime.mockReset() + fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -131,7 +134,8 @@ export function installRuntimeFileClientEnvironment(): void { finishDownloadedFile: fsFinishDownloadedFile, cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, - stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 7e274343d0a..cfbec0ac146 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -21,25 +21,6 @@ import { import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -type StagedRuntimeImportSource = - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: StagedRuntimeImportEntry[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { sourcePath: string; status: 'failed'; reason: string } - -type StagedRuntimeImportEntry = - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - type RuntimeImportResult = | { sourcePath: string @@ -113,7 +94,7 @@ export async function importExternalPathsToRuntime( await ensureRuntimeDirectory(context, destinationDir, importSession) - for (const source of staged.sources as StagedRuntimeImportSource[]) { + for (const source of staged.sources) { if (source.status !== 'staged') { results.push(source) continue @@ -150,7 +131,16 @@ export async function importExternalPathsToRuntime( importSession, context.worktreeId, entryRelativePath, - entry.contentBase64, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + } + }, context.expectedSshConnectionGeneration, context.expectedSshTargetId, context.expectedExecutionHostId ?? diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index 34da21c8966..eb8dad53970 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -32,6 +32,7 @@ type RuntimeCallArgs = { const runtimeEnvironmentCall = vi.fn<(args: RuntimeCallArgs) => unknown>() const stageExternalPathsForRuntimeUpload = vi.fn() +const uploadExternalFileToRuntime = vi.fn<(args: Record) => unknown>() const importExternalPaths = vi.fn() const nestedSshContext = { @@ -99,7 +100,8 @@ function repairedRuntimeResponse(method: string) { } } -function mockStagedFile(sourcePath: string, name: string, contentBase64: string): void { +/** Staging now hands over identity, not a body; the streamer in main reads the bytes. */ +function mockStagedFile(sourcePath: string, name: string, byteLength: number): void { stageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -107,12 +109,28 @@ function mockStagedFile(sourcePath: string, name: string, contentBase64: string) status: 'staged', name, kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64 }] + entries: [ + { + relativePath: '', + kind: 'file', + byteLength, + inode: 91, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } + ] } ] }) } +function expectUploadsBoundToCapturedRevision(): void { + for (const [args] of uploadExternalFileToRuntime.mock.calls) { + expect(args.expectedEnvironmentPairingRevision).toBe(CAPTURED_REVISION) + expect(args.expectedEnvironmentRuntimeId).toBe('hub-runtime') + } +} + function expectEveryRuntimeCallBoundToCapturedRevision(ownership: { expectedExecutionHostId: string expectedSshTargetId?: string @@ -146,12 +164,15 @@ beforeEach(() => { markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) runtimeEnvironmentCall.mockReset() stageExternalPathsForRuntimeUpload.mockReset() + uploadExternalFileToRuntime.mockReset() + uploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) importExternalPaths.mockReset() vi.stubGlobal('window', { api: { fs: { importExternalPaths, - stageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime }, runtimeEnvironments: { call: runtimeEnvironmentCall @@ -183,7 +204,7 @@ describe('runtime file import pairing revision', () => { }) it('stops when the HUB runtime changes without a pairing change', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -191,14 +212,15 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setRuntimeEnvironmentConnectionGenerationForTests( - ENVIRONMENT_ID, - REPLACEMENT_CONNECTION_GENERATION - ) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setRuntimeEnvironmentConnectionGenerationForTests( + ENVIRONMENT_ID, + REPLACEMENT_CONNECTION_GENERATION + ) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -208,9 +230,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) + expectUploadsBoundToCapturedRevision() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -236,8 +258,8 @@ describe('runtime file import pairing revision', () => { expect(importExternalPaths).not.toHaveBeenCalled() }) - it('stops a rich-markdown upload between chunks without contacting the replacement HUB', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + it('never commits a streamed upload against a replacement HUB re-paired mid-stream', async () => { + mockStagedFile('/client/screenshot.png', 'screenshot.png', 40 * 1024 * 1024) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -245,11 +267,12 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockImplementation(async () => { + setEnvironmentRevision(REPLACEMENT_REVISION) + return { byteLength: 40 * 1024 * 1024 } + }) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/screenshot.png'], '/ssh/repo') @@ -259,20 +282,9 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - method: 'files.writeBase64Chunk', - expectedEnvironmentPairingRevision: CAPTURED_REVISION, - params: expect.objectContaining({ - contentBase64: 'A'.repeat(512 * 1024), - append: false - }) - }) - ) + expectUploadsBoundToCapturedRevision() expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) @@ -283,7 +295,7 @@ describe('runtime file import pairing revision', () => { }) it('keeps a HUB-local composer commit on its entry revision when re-paired during commit', async () => { - mockStagedFile('/client/note.txt', 'note.txt', 'bm90ZQ==') + mockStagedFile('/client/note.txt', 'note.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.expectedEnvironmentPairingRevision !== CAPTURED_REVISION) { throw new Error('replacement HUB received an import RPC') @@ -310,14 +322,13 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(hubLocalContext) }) it('does not clean up against a replacement HUB after commit', async () => { - mockStagedFile('/client/drop.txt', 'drop.txt', 'ZHJvcA==') + mockStagedFile('/client/drop.txt', 'drop.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -340,7 +351,6 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', 'files.stat', - 'files.writeBase64', 'files.commitUpload' ]) expectEveryRuntimeCallBoundToCapturedRevision(nestedSshContext) @@ -356,7 +366,14 @@ describe('runtime file import pairing revision', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'broken.txt', kind: 'file', contentBase64: 'YnJva2Vu' } + { + relativePath: 'broken.txt', + kind: 'file', + byteLength: 6, + inode: 92, + deviceId: 66, + modifiedAtMs: 1_700_000_000_000 + } ] } ] @@ -368,16 +385,9 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64') { - return { - id: args.method, - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'hub-runtime' } - } - } return successfulRuntimeResponse(args.method) }) + uploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) await expect( importExternalPathsToRuntime(nestedSshContext, ['/client/assets'], '/ssh/repo') @@ -387,7 +397,6 @@ describe('runtime file import pairing revision', () => { 'status.get', 'files.stat', 'files.createDirNoClobber', - 'files.writeBase64', 'files.delete', 'files.delete' ]) diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index 2f3c1582ec5..fc3d0f83a9a 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -1,4 +1,6 @@ +import { extractIpcErrorMessage } from '@/lib/ipc-error' import { joinPath, normalizeRelativePath } from '@/lib/path' +import type { StagedRuntimeUploadFileIdentity } from '../../../shared/runtime-upload-staging-contract' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { callRuntimeFileImportMutation, @@ -12,28 +14,48 @@ import { import { runtimePathExists } from './runtime-file-metadata-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 +/** Locates a staged file on the client so main can stream it without the renderer reading it. */ +export type RuntimeUploadSource = { + sourceRootPath: string + entryRelativePath: string + /** What staging observed; main refuses the upload if the source no longer matches. */ + expected: StagedRuntimeUploadFileIdentity +} +/** Stream one staged file to a temp path, then commit it; the temp path is always cleaned up. */ export async function uploadRuntimeFileWithoutClobber( session: RuntimeFileImportSession, worktreeId: string, relativePath: string, - contentBase64: string, + source: RuntimeUploadSource, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}` ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { - await writeRuntimeBase64File( - session, - worktreeId, - tempRelativePath, - contentBase64, - expectedSshConnectionGeneration, - expectedSshTargetId, - expectedExecutionHostId - ) + session.assertCurrent() + // Why: main owns the file handle and the runtime socket, so it streams the + // body in slices; the renderer never holds the whole file. + try { + await window.api.fs.uploadExternalFileToRuntime({ + environmentId: session.target.environmentId, + sourceRootPath: source.sourceRootPath, + entryRelativePath: source.entryRelativePath, + expected: source.expected, + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath: tempRelativePath, + expectedSshTargetId, + expectedSshConnectionGeneration, + expectedExecutionHostId, + expectedEnvironmentPairingRevision: session.expectedEnvironmentPairingRevision, + expectedEnvironmentRuntimeId: session.expectedEnvironmentRuntimeId + }) + } catch (error) { + // Why: this surfaces in the import result as-is, and Electron wraps a + // main-process throw in "Error invoking remote method '…'". + throw new Error(extractIpcErrorMessage(error, 'Upload failed')) + } await callRuntimeFileImportMutation( session, 'files.commitUpload', @@ -64,50 +86,7 @@ export async function uploadRuntimeFileWithoutClobber( } } -async function writeRuntimeBase64File( - session: RuntimeFileImportSession, - worktreeId: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}` -): Promise { - if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - return - } - - for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - await callRuntimeFileImportMutation( - session, - 'files.writeBase64Chunk', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), - append: offset > 0, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000 - ) - } -} - +/** Hidden sibling of the destination, so a failed upload never leaves a plausible-looking file. */ function makeRuntimeUploadTempPath(relativePath: string): string { const normalized = normalizeRelativePath(relativePath) const slashIndex = normalized.lastIndexOf('/') diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index dcf93839828..889e89b5103 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -126,6 +126,11 @@ export function createFileApi(): NonNullable['fs']> { }, importExternalPaths: async () => ({ results: [] }), stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + // Why: the web client has no local filesystem to stream from, so staging + // never yields a source for this to upload. + uploadExternalFileToRuntime: async () => { + throw new Error('Uploading local files is not supported in the web client') + }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts new file mode 100644 index 00000000000..19a3d2f6261 --- /dev/null +++ b/src/shared/runtime-upload-staging-contract.ts @@ -0,0 +1,50 @@ +import type { SshMutationExpectation } from './ssh-types' + +export type RuntimeUploadSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + +/** + * What staging observed about a file, so the uploader can refuse a source that + * was swapped between the two calls. Size alone misses a same-size replacement. + */ +export type StagedRuntimeUploadFileIdentity = { + byteLength: number + /** 0 when the filesystem does not report one; compared only when both sides have it. */ + inode: number + deviceId: number + modifiedAtMs: number +} + +export type StagedRuntimeUploadEntry = + | { relativePath: string; kind: 'directory' } + // Why: file bodies are streamed in slices at upload time, so staging carries + // identity the uploader re-checks against the handle it actually reads. + | ({ relativePath: string; kind: 'file' } & StagedRuntimeUploadFileIdentity) + +export type StagedRuntimeUploadSource = + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: StagedRuntimeUploadEntry[] + } + | { sourcePath: string; status: 'skipped'; reason: RuntimeUploadSkipReason } + | { sourcePath: string; status: 'failed'; reason: string } + +export type StageRuntimeUploadResult = { sources: StagedRuntimeUploadSource[] } + +/** Renderer → main request to pump one staged file's bytes to the runtime. */ +export type RuntimeUploadFileStreamRequest = { + environmentId: string + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + /** Identity staging recorded; a source that no longer matches is refused, not streamed. */ + expected: StagedRuntimeUploadFileIdentity + worktree: string + /** Destination path on the runtime, relative to the worktree. */ + relativePath: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId?: string +} & SshMutationExpectation From 389d672dab3963f098f269a36f0ffb4a148a7d90 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:17:04 -0700 Subject: [PATCH 11/13] fix(omp): preserve saved conversation names in session history (#20636) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- docs/reference/omp-history-titles.md | 34 +++++ .../ai-vault/session-scanner-graph-parsers.ts | 54 +++++++- .../session-scanner-omp-title.test.ts | 131 ++++++++++++++++++ .../ai-vault/session-scanner-omp-title.ts | 49 +++++++ tests/tools/omp-history-title-smoke.mjs | 92 ++++++++++++ 5 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 docs/reference/omp-history-titles.md create mode 100644 src/main/ai-vault/session-scanner-omp-title.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-title.ts create mode 100644 tests/tools/omp-history-title-smoke.mjs diff --git a/docs/reference/omp-history-titles.md b/docs/reference/omp-history-titles.md new file mode 100644 index 00000000000..f238574ef2a --- /dev/null +++ b/docs/reference/omp-history-titles.md @@ -0,0 +1,34 @@ +# OMP history titles + +The message-graph scanner uses persisted OMP names ahead of the first user prompt: +`session.title`, version-1 `title` slots, `title_change.title`, and legacy +`session_info.name`. Empty or unsupported metadata leaves the previous name or +prompt fallback intact. Non-OMP graph parsing keeps its existing title policy. + +Explicit user names outrank automatic names. Within the same source, timestamps +prevent the current first-line title slot from being replaced by older rename +entries later in the file. Newer appended renames still update the row. Legacy +records without timestamps retain file-order handling. + +The graph fold stores title authority alongside the existing accumulator. Clones +retain it without sharing mutable accumulator or preview state, while preserving +the existing identity and message-consumer contracts. Cached append parsing uses +the normal durable offset; no extra scan, process, poll or watcher is introduced. + +The parser is shared by local and remote content readers and uses transcript data +from the execution host. It performs no client-side path lookup and changes no +wire shape. Folder workspaces require no git metadata. + +Run actual persistence and cache validation with a read-only OMP checkout: + +```sh +ORCA_BACKGROUND_LAUNCH=1 bun tests/tools/omp-history-title-smoke.mjs /path/to/oh-my-pi +``` + +The smoke persists a first prompt, performs a real OMP user rename, and verifies +both cold and incrementally cached scans. It checks one full parse, one append +parse and identical-object reuse on an unchanged scan. All home/config/data roots +are disposable; no model requests are made. + +This is the OMP subset of the history-name behavior proposed in PR #15696 by +Brennan Benson. Pi naming and title changes in the terminal are separate concerns. diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 3ca4e754106..353decfcd46 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,3 +1,4 @@ +import { foldOmpTranscriptTitle, type OmpTranscriptTitle } from './session-scanner-omp-title' import { remoteSessionContentLines, type RemoteSessionContent @@ -15,7 +16,8 @@ import type { } from './session-scanner-types' import type { TranscriptMessageSink } from './session-transcript-consumers' import { - accumulatorFoldResumeState, + accumulatorSessionIdentity, + cloneSessionAccumulator, addPreviewContent, addPreviewMessage, createAccumulator, @@ -212,12 +214,24 @@ export async function parseMessageGraphSessionContent( }) } -function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void { +type MessageGraphParseState = { + accumulator: SessionAccumulator + ompTitle: OmpTranscriptTitle | null +} + +function consumeMessageGraphRecordLine(state: MessageGraphParseState, line: string): void { + const { accumulator } = state const record = parseJsonObject(line) if (!record) { return } updateTimeline(accumulator, extractString(record.timestamp)) + if (accumulator.agent === 'omp') { + state.ompTitle = foldOmpTranscriptTitle(state.ompTitle, record) + if (state.ompTitle) { + accumulator.title = state.ompTitle.title + } + } if (record.type === 'session') { const sessionId = extractString(record.id) if (sessionId) { @@ -241,7 +255,11 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st if (role === 'user' || role === 'assistant') { accumulator.messageCount++ if (role === 'user') { - accumulator.title ??= extractMessageText(message) + if (accumulator.agent === 'omp') { + accumulator.fallbackTitle ??= extractMessageText(message) + } else { + accumulator.title ??= extractMessageText(message) + } } else { accumulator.model = extractString(message?.model) ?? accumulator.model accumulator.totalTokens += tokenTotal(message?.usage) @@ -255,16 +273,38 @@ export function createMessageGraphSessionResumeState( file: FileWithMtime, messages?: TranscriptMessageSink ): ResumableSessionParseState { - const state = accumulatorFoldResumeState( - createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path), messages }), - consumeMessageGraphRecordLine - ) + const state = createMessageGraphResumeState({ + accumulator: createAccumulator({ + agent, + file, + sessionId: sessionIdFromFileName(file.path), + messages + }), + ompTitle: null + }) // Why: only OMP materializes task-subagent transcripts beside its sessions // (in the same-named artifact dir); the row UI shows the count without // expanding details. Pi/OpenClaw/Prime Agent have no such layout — skip the readdir. return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } +function createMessageGraphResumeState(state: MessageGraphParseState): ResumableSessionParseState { + return { + consumeLine: (line) => consumeMessageGraphRecordLine(state, line), + identity: () => accumulatorSessionIdentity(state.accumulator), + clone: () => + createMessageGraphResumeState({ + accumulator: cloneSessionAccumulator(state.accumulator), + ompTitle: state.ompTitle + }), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => + finalizeSession(cloneSessionAccumulator(state.accumulator), platform, options) + } +} + async function parseMessageGraphSessionLines(args: { agent: MessageGraphAgent file: FileWithMtime diff --git a/src/main/ai-vault/session-scanner-omp-title.test.ts b/src/main/ai-vault/session-scanner-omp-title.test.ts new file mode 100644 index 00000000000..3e2d6a966cf --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + createMessageGraphSessionResumeState, + parseMessageGraphSessionContent +} from './session-scanner-graph-parsers' + +const file = { path: '/tmp/omp-title.jsonl', mtimeMs: 1, modifiedAt: '2026-09-14T00:00:00.000Z' } +const prompt = { type: 'message', message: { role: 'user', content: 'First prompt' } } +const header = { type: 'session', id: 'session-id', cwd: '/folder workspace' } +const line = (record: unknown) => JSON.stringify(record) +async function parse(records: unknown[], agent: 'omp' | 'pi' = 'omp') { + return parseMessageGraphSessionContent( + agent, + file, + [header, ...records].map(line).join('\n'), + 'darwin' + ) +} + +describe('OMP stored history names', () => { + it.each([ + { type: 'session', title: 'Harness name', titleSource: 'user' }, + { + type: 'title', + v: 1, + title: 'Harness name', + source: 'user', + updatedAt: '2026-09-14T01:00:00Z', + pad: '' + }, + { type: 'title_change', title: 'Harness name', source: 'user' }, + { type: 'session_info', name: 'Harness name' } + ])('uses persisted %j ahead of the first prompt', async (record) => { + expect((await parse([prompt, record]))?.title).toBe('Harness name') + }) + + it('preserves a user name through stale header and later automatic records', async () => { + expect( + ( + await parse([ + { + type: 'title', + v: 1, + title: 'User name', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + { ...header, title: 'Old header' }, + prompt, + { + type: 'title_change', + title: 'Auto name', + source: 'auto', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('User name') + }) + + it('keeps the current slot ahead of older rename entries, allowing a newer rename', async () => { + const records = [ + { + type: 'title', + v: 1, + title: 'Current slot', + source: 'user', + updatedAt: '2026-09-14T02:00:00Z', + pad: '' + }, + prompt, + { + type: 'title_change', + title: 'Old rename', + source: 'user', + timestamp: '2026-09-14T01:00:00Z' + } + ] + expect((await parse(records))?.title).toBe('Current slot') + expect( + ( + await parse([ + ...records, + { + type: 'title_change', + title: 'New rename', + source: 'user', + timestamp: '2026-09-14T03:00:00Z' + } + ]) + )?.title + ).toBe('New rename') + }) + + it('preserves fallback behavior for missing, empty or unsupported title records', async () => { + expect( + ( + await parse([ + prompt, + { type: 'title_change', title: ' ', source: 'user' }, + { type: 'title_change', title: 'Unknown', source: 'model' }, + { type: 'session_info', title: 'Wrong field' } + ]) + )?.title + ).toBe('First prompt') + expect( + (await parse([prompt, { type: 'title_change', title: 'OMP only', source: 'user' }], 'pi')) + ?.title + ).toBe('First prompt') + }) + + it('clones title authority for append parsing without mutating previous snapshots', async () => { + const state = createMessageGraphSessionResumeState('omp', file) + for (const record of [ + header, + prompt, + { type: 'title_change', title: 'User name', source: 'user' } + ]) { + state.consumeLine(line(record)) + } + const previous = await state.finalize('darwin') + const next = state.clone() + next.consumeLine(line({ type: 'title_change', title: 'Auto name', source: 'auto' })) + expect((await next.finalize('darwin'))?.title).toBe('User name') + next.consumeLine(line({ type: 'title_change', title: 'New name', source: 'user' })) + expect((await next.finalize('darwin'))?.title).toBe('New name') + expect(previous?.title).toBe('User name') + expect(state.identity?.()?.title).toBe('User name') + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-title.ts b/src/main/ai-vault/session-scanner-omp-title.ts new file mode 100644 index 00000000000..e734350caad --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-title.ts @@ -0,0 +1,49 @@ +import { extractString, normalizeTitleText, timestampMs } from './session-scanner-values' + +export type OmpTranscriptTitle = { + title: string + source: 'user' | 'auto' + updatedAt: number | null +} + +/** Fold persisted title metadata; a current slot can precede older rename entries. */ +export function foldOmpTranscriptTitle( + current: OmpTranscriptTitle | null, + record: Record +): OmpTranscriptTitle | null { + const legacy = record.type === 'session_info' + if ( + !legacy && + record.type !== 'session' && + record.type !== 'title_change' && + record.type !== 'title' + ) { + return current + } + if (record.type === 'title' && record.v !== 1) { + return current + } + const title = normalizeTitleText(extractString(legacy ? record.name : record.title) ?? '') + if (!title) { + return current + } + const rawSource = legacy ? 'user' : (record.source ?? record.titleSource) + if (rawSource !== undefined && rawSource !== 'user' && rawSource !== 'auto') { + return current + } + const source = rawSource === 'user' ? 'user' : 'auto' + if (current?.source === 'user' && source !== 'user') { + return current + } + const timestamp = timestampMs(record.type === 'title' ? record.updatedAt : record.timestamp) + const updatedAt = Number.isFinite(timestamp) ? timestamp : null + if ( + current?.source === source && + current.updatedAt !== null && + updatedAt !== null && + updatedAt < current.updatedAt + ) { + return current + } + return { title, source, updatedAt } +} diff --git a/tests/tools/omp-history-title-smoke.mjs b/tests/tools/omp-history-title-smoke.mjs new file mode 100644 index 00000000000..7c5d84aaa2c --- /dev/null +++ b/tests/tools/omp-history-title-smoke.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +assert.ok(process.argv[2], 'Pass a read-only OMP checkout') +const orcaRoot = fileURLToPath(new URL('../../', import.meta.url)) +const scratch = await mkdtemp(join(tmpdir(), 'orca-omp-history-title-')) +process.env.HOME = join(scratch, 'home') +process.env.USERPROFILE = process.env.HOME +for (const [key, value] of Object.entries({ + XDG_CONFIG_HOME: 'config', + XDG_DATA_HOME: 'data', + XDG_STATE_HOME: 'state', + XDG_CACHE_HOME: 'cache' +})) { + process.env[key] = join(scratch, value) +} +for (const key of [ + 'OMP_CODING_AGENT_DIR', + 'PI_CODING_AGENT_DIR', + 'OMP_PROFILE', + 'PI_PROFILE', + 'PI_CONFIG_DIR', + 'PI_CONFIG_FILES' +]) { + delete process.env[key] +} +await mkdir(process.env.HOME, { recursive: true }) +const source = (root, path) => pathToFileURL(join(resolve(root), path)).href +const { SessionManager } = await import( + source(process.argv[2], 'packages/coding-agent/src/session/session-manager.ts') +) +const { parseMessageGraphSessionFile } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-graph-parsers.ts') +) +const { createSessionParseStats, parseAgentSessionFileCached } = await import( + source(orcaRoot, 'src/main/ai-vault/session-scanner-parse-cache.ts') +) +const stats = createSessionParseStats() +const manager = SessionManager.create(scratch, join(scratch, 'sessions')) +try { + manager.appendMessage({ role: 'user', content: 'Original first prompt', timestamp: Date.now() }) + await manager.ensureOnDisk() + await manager.flush() + const candidate = async () => { + const details = await stat(manager.getSessionFile()) + return { + agent: 'omp', + codexHome: null, + file: { + path: manager.getSessionFile(), + mtimeMs: details.mtimeMs, + modifiedAt: details.mtime.toISOString(), + sizeBytes: details.size + } + } + } + const initial = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(initial.title, 'Original first prompt') + await manager.setSessionName('Explicit renamed conversation', 'user') + await manager.flush() + const path = manager.getSessionFile() + const details = await stat(path) + const parsed = await parseMessageGraphSessionFile( + 'omp', + { path, mtimeMs: details.mtimeMs, modifiedAt: details.mtime.toISOString() }, + process.platform + ) + const refreshed = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(parsed?.title, manager.getSessionName()) + assert.equal(refreshed?.title, manager.getSessionName()) + assert.equal(stats.fullParses, 1) + assert.equal(stats.incremental, 1) + assert.equal(initial.title, 'Original first prompt') + const reused = await parseAgentSessionFileCached(await candidate(), process.platform, stats) + assert.equal(reused, refreshed) + console.log( + JSON.stringify({ + actualOmpPersistence: true, + renamedTitlePreserved: true, + cachedRenamePreserved: true, + unchangedSnapshotReused: true, + fullParses: stats.fullParses, + incrementalParses: stats.incremental, + modelCalls: 0 + }) + ) +} finally { + await manager.close() + await rm(scratch, { recursive: true, force: true }) +} From 46eb5959fa783e55852ce14aa0123b7b3e3f423a Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:27:31 +0900 Subject: [PATCH 12/13] fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idle agent pane kept ~40% of a core busy just by being frontmost. The xterm cursor and the native chat caret blink with no paint-containment boundary, so Chromium treated each blink as damage to the whole pane ancestry and re-rasterized it twice a second. - `.xterm-container` and the native composer's input shell get `contain: paint`, bounding blink damage to the surface that blinks. - The mention hint gains `z-20` to match the slash picker: a contained element becomes a stacking context and paints at z-index 0 in tree order, which would otherwise cover the hint's drop shadow. Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins over the option in `_updateCursorBlink` — so parking `cursorBlink` does not reliably stop a hidden pane blinking. Pre-existing, documented only. Co-authored-by: Wooseong Kim --- .../terminal-container-geometry.test.ts | 4 +++ src/renderer/src/assets/terminal.css | 4 +++ .../NativeChatAutocompleteMenus.tsx | 5 ++- .../native-chat/NativeChatComposerField.tsx | 10 +++++- .../native-chat-composer-containment.test.ts | 33 +++++++++++++++++++ .../pane-cursor-blink-suspension.ts | 8 ++++- 6 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts diff --git a/src/renderer/src/assets/terminal-container-geometry.test.ts b/src/renderer/src/assets/terminal-container-geometry.test.ts index db079c9095f..cf17cb00540 100644 --- a/src/renderer/src/assets/terminal-container-geometry.test.ts +++ b/src/renderer/src/assets/terminal-container-geometry.test.ts @@ -15,4 +15,8 @@ describe('terminal container geometry', () => { /\.pane-link-tooltip\s*{[^}]*height:\s*var\(--orca-terminal-link-tooltip-height\);/s ) }) + + it('bounds cursor-blink repaints to the terminal surface (#10481)', () => { + expect(terminalCss).toMatch(/\.xterm-container\s*{[^}]*contain:\s*paint;/s) + }) }) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 1ee09586892..0d17b1f77df 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -512,6 +512,10 @@ height: calc(100% - var(--pane-padding-y, 4px)); margin-top: var(--pane-padding-y, 4px); margin-left: var(--pane-padding-x, 4px); + /* Why (#10481): a blinking cursor otherwise invalidates paint all the way up + the pane ancestry. The link tooltip and drag handle are .pane siblings, so + clipping to this box costs no visible chrome. */ + contain: paint; } /* When a pane has a title, shift the terminal content down to make room. diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index 125d4326b8f..2e1d05e0ffe 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -276,7 +276,10 @@ export function NativeChatMentionHint({ event.preventDefault() onAccept() }} - className="absolute bottom-full left-3 right-3 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4" + // Why z-20: matches the slash picker. The composer shell below is a paint + // containment boundary (#10481), so it now paints at z-index 0 in tree + // order and would otherwise cover this hint's drop shadow. + className="absolute bottom-full left-3 right-3 z-20 mb-1 flex w-auto items-center gap-2 rounded-md border border-border bg-popover px-3 py-1.5 text-left text-xs text-muted-foreground shadow-md sm:left-4 sm:right-4" > {translate('components.native-chat.composer.mentionHint', 'Referencing file:')}{' '} @{query || '…'} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index f51bf4c5400..a4b104efd0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -192,7 +192,15 @@ export function NativeChatComposerField({ // no focus/click border flash. The box is a container, not a // focus target. 'rounded-lg border border-border p-1.5 shadow-xs', - 'bg-muted/50 dark:bg-input/40' + 'bg-muted/50 dark:bg-input/40', + // Why (#10481): the native caret blink invalidates paint up to the + // nearest containment boundary; without this the whole transcript + // re-rasterizes twice a second. Pickers are siblings and every menu + // and tooltip in here is a Radix portal, so nothing floating clips. + // Tightest descendant is the attachment remove button, which + // overhangs its thumbnail by 6px and clears this box's padding by + // 4px — keep that slack if the padding below ever shrinks. + '[contain:paint]' )} > {imageAttachments.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts new file mode 100644 index 00000000000..2c8a08ffa89 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const composerField = fs.readFileSync( + new URL('./NativeChatComposerField.tsx', import.meta.url), + 'utf8' +) +const autocompleteMenus = fs.readFileSync( + new URL('./NativeChatAutocompleteMenus.tsx', import.meta.url), + 'utf8' +) + +describe('native chat composer paint containment (#10481)', () => { + it('bounds caret repaints to the composer input shell', () => { + expect(composerField).toContain('[contain:paint]') + }) + + it('keeps the outer composer uncontained so the pickers can overflow it', () => { + // The pickers are siblings that render above the shell via `bottom-full`; + // containing their parent would clip them. + const outerShell = composerField.slice(0, composerField.indexOf('[contain:paint]')) + expect(outerShell).toContain('
') + expect(outerShell).not.toContain('contain:paint') + }) + + it('lifts both pickers above the contained shell', () => { + // The shell is a stacking context now, so it paints at z-index 0 in tree + // order — an unlayered picker would lose its drop shadow to it. + for (const picker of ['bottom-full left-0 right-0 z-20', 'bottom-full left-3 right-3 z-20']) { + expect(autocompleteMenus).toContain(picker) + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts index 13f90af696c..6cdb5dd7508 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -12,10 +12,16 @@ import type { Terminal } from '@xterm/xterm' * and the pane blinks — redrawing its whole cursor row through * `WebglRenderer._updateModel` — until the 5-minute idle timeout. * - * `cursorBlink` is the public option that tears the timer down deterministically + * `cursorBlink` is the public option that tears the timer down * (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so * "a hidden pane does not blink" stops depending on which CSS hid it. * + * Not unconditional, though: `_updateCursorBlink` resolves + * `decPrivateModes.cursorBlink ?? options.cursorBlink`, and DECSCUSR with a + * blinking style (`CSI 5 SP q`) pins that DEC mode. On a pane whose shell or agent + * has emitted one, parking the option here has no effect and the hidden pane keeps + * blinking. Making this deterministic means clearing the DEC mode too. + * * Resume restores the parked value rather than the settings value, so a pane that * was not blinking before the hide never comes back blinking. */ From d51747e4c40b600db52658daaee2cb55dc0d2d05 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:32:49 -0400 Subject: [PATCH 13/13] feat(relay): expose preloaded PostgreSQL statement statistics (#20712) --- .../src/database-postgres-timeout.test.ts | 8 +- cloud/apps/relay/src/database.ts | 2 + .../postgres-statement-stats-postgres.test.ts | 121 ++++++++++++++++++ .../relay/src/postgres-statement-stats.ts | 28 ++++ cloud/docs/orca-relay-operations.md | 15 +++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts create mode 100644 cloud/apps/relay/src/postgres-statement-stats.ts diff --git a/cloud/apps/relay/src/database-postgres-timeout.test.ts b/cloud/apps/relay/src/database-postgres-timeout.test.ts index f678fecc4bb..df300383c1b 100644 --- a/cloud/apps/relay/src/database-postgres-timeout.test.ts +++ b/cloud/apps/relay/src/database-postgres-timeout.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' const fakes = vi.hoisted(() => ({ configs: [] as Array>, @@ -119,11 +120,16 @@ describe('PostgreSQL relay deadlines', () => { }) expect(ddl.length).toBeGreaterThan(0) + expect(ddl).toContain(POSTGRES_STATEMENT_STATS_MIGRATION) // Statements can open with a leading `--` rationale comment. const body = (statement: string): string => statement.replace(/^(?:\s*--[^\n]*\n)*\s*/, '') expect( - ddl.every((statement) => /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement))) + ddl.every( + (statement) => + statement === POSTGRES_STATEMENT_STATS_MIGRATION || + /^(?:CREATE|ALTER TABLE)\b/i.test(body(statement)) + ) ).toBe(true) // The backfill is DML, so it stays on the deadline-bearing serving pool. expect(ddl.some((statement) => statement.includes('INSERT INTO'))).toBe(false) diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 41ead67ea60..dee6a16e523 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -10,6 +10,7 @@ import { type PostgresPoolPressureCounts } from './postgres-pool-pressure.js' import { applyPostgresSchema } from './postgres-schema-startup.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' import { CellInventoryHoldSamples, emptyCellInventoryHoldCounts, @@ -619,6 +620,7 @@ CREATE INDEX IF NOT EXISTS relay_audit_events_at ON relay_audit_events(at); // auto-named; the replacement is named, so both statements are no-ops on a // database the current schema created and neither can drop the other. export const POSTGRES_SCHEMA_MIGRATIONS = [ + POSTGRES_STATEMENT_STATS_MIGRATION, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS last_considered_at BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_decisions ADD COLUMN IF NOT EXISTS cohort_bucket BIGINT NOT NULL DEFAULT 0`, `ALTER TABLE relay_region_rehome_attempts diff --git a/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts new file mode 100644 index 00000000000..251e3670759 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats-postgres.test.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto' +import pg from 'pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openRelayDatabase } from './database.js' +import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js' + +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip + +describePostgres('optional PostgreSQL statement statistics', () => { + let admin: pg.Client + let preloaded: boolean + const databases: string[] = [] + const roles: string[] = [] + + beforeAll(async () => { + admin = new pg.Client({ connectionString: databaseUrl }) + await admin.connect() + const result = await admin.query<{ loaded: boolean }>( + `SELECT 'pg_stat_statements' = ANY(string_to_array( + replace(current_setting('shared_preload_libraries'), ' ', ''), ',' + )) AS loaded` + ) + preloaded = result.rows[0]!.loaded + }) + + afterAll(async () => { + for (const database of databases) await admin.query(`DROP DATABASE IF EXISTS ${database}`) + for (const role of roles) await admin.query(`DROP ROLE IF EXISTS ${role}`) + await admin.end() + }) + + async function freshDatabase(): Promise { + const name = `relay_stats_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE DATABASE ${name}`) + databases.push(name) + const url = new URL(databaseUrl!) + url.pathname = `/${name}` + return url.toString() + } + + async function connect(url: string): Promise { + const client = new pg.Client({ connectionString: url, statement_timeout: 2_000 }) + await client.connect() + return client + } + + async function installed(client: pg.Client): Promise { + const result = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') AS present` + ) + return result.rows[0]!.present + } + + it('exposes an existing collector idempotently, and skips servers without one', async () => { + const url = await freshDatabase() + const database = await openRelayDatabase({ databaseUrl: url, dataDir: '' }) + await database.close() + const client = await connect(url) + try { + expect(await installed(client)).toBe(preloaded) + if (preloaded) { + const before = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + const after = await client.query('SELECT stats_reset FROM public.pg_stat_statements_info') + expect(after.rows).toEqual(before.rows) + await client.query('SELECT calls, wal_bytes, shared_blks_dirtied FROM public.pg_stat_statements LIMIT 1') + } else { + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + } + } finally { + await client.end() + } + }) + + it.each([false, true])('tolerates missing extension privileges (read settings: %s)', async (readSettings) => { + const client = await connect(await freshDatabase()) + const role = `relay_stats_role_${randomUUID().replaceAll('-', '')}` + await admin.query(`CREATE ROLE ${role}`) + roles.push(role) + if (readSettings) await admin.query(`GRANT pg_read_all_settings TO ${role}`) + try { + await client.query(`SET ROLE ${role}`) + await client.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(client)).toBe(false) + expect((await client.query<{ value: number }>('SELECT 42 AS value')).rows[0]!.value).toBe(42) + } finally { + await client.end() + } + }) + + it('serializes concurrent catalog creation across directors', async () => { + const url = await freshDatabase() + const clients = await Promise.all(Array.from({ length: 5 }, async () => await connect(url))) + try { + await Promise.all(clients.map(async (client) => await client.query(POSTGRES_STATEMENT_STATS_MIGRATION))) + expect(await installed(clients[0]!)).toBe(preloaded) + } finally { + await Promise.all(clients.map(async (client) => await client.end())) + } + }) + + it('yields to an in-progress installer instead of blocking startup', async () => { + const url = await freshDatabase() + const owner = await connect(url) + const contender = await connect(url) + try { + await owner.query('BEGIN') + await owner.query(`SELECT pg_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats'))`) + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(false) + await owner.query('COMMIT') + await contender.query(POSTGRES_STATEMENT_STATS_MIGRATION) + expect(await installed(contender)).toBe(preloaded) + } finally { + await owner.end() + await contender.end() + } + }) +}) diff --git a/cloud/apps/relay/src/postgres-statement-stats.ts b/cloud/apps/relay/src/postgres-statement-stats.ts new file mode 100644 index 00000000000..61a2fee3b75 --- /dev/null +++ b/cloud/apps/relay/src/postgres-statement-stats.ts @@ -0,0 +1,28 @@ +// Expose an already-running collector; never preload a module or require elevated runtime privileges. +export const POSTGRES_STATEMENT_STATS_MIGRATION = ` +DO $relay_statement_stats$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_settings + WHERE name = 'shared_preload_libraries' + AND 'pg_stat_statements' = ANY(string_to_array(replace(setting, ' ', ''), ',')) + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_stat_statements' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'pg_stat_statements' + ) THEN + RETURN; + END IF; + + IF NOT pg_try_advisory_xact_lock(hashtext('orca-relay'), hashtext('statement-stats')) THEN + RETURN; + END IF; + + BEGIN + CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; + EXCEPTION WHEN insufficient_privilege THEN + RAISE WARNING 'orca_relay_statement_stats_unavailable: insufficient privilege'; + END; +END +$relay_statement_stats$; +` diff --git a/cloud/docs/orca-relay-operations.md b/cloud/docs/orca-relay-operations.md index 4515048b29b..ea5717daa87 100644 --- a/cloud/docs/orca-relay-operations.md +++ b/cloud/docs/orca-relay-operations.md @@ -2,6 +2,21 @@ This runbook applies to the stable Cloud Run director and the production-shaped GCE cells in both environments. It does not authorize a full Terraform apply: staging and production contain unrelated drift, so inspect a saved targeted plan and its destroy count before every apply. +## PostgreSQL statement statistics + +Relay schema startup exposes `pg_stat_statements` when the server already preloads +that collector and the schema identity can install its extension. Servers without +the collector or the required privileges continue normally. Installation does not +change preload settings, reset collected counters, or require a database restart; +concurrent startups yield to one installer. An existing extension is left in place. + +For SQL incidents, inspect bounded aggregates of `calls`, `total_exec_time`, +`shared_blks_read`, `shared_blks_dirtied`, and `wal_bytes`, scoped to the relay +database and identified query IDs. Compare counter deltas over the same interval +as fleet runtime metrics; retain the statistics reset timestamp. Do not export +query text, identities, credentials, or invoke `pg_stat_statements_reset()` during +an investigation. Treat an unavailable view as missing evidence, not zero work. + The relay is automatically active for entitled signed-in desktops. There is no rollout flag, cohort, or user toggle. The emergency product kill switch is the auth plane refusing relay-token exchange; use cell drains only to move or terminate existing data-plane work. ## Safety rules